Skill: sdk-team/alibabacloud-quickbi-smartq (version 0.0.1)

--- name: alibabacloud-quickbi-smartq description: | Quick BI-SmartQ skill with multiple data analysis capabilities: 1. **File Q&A**: Upload Excel/CSV files for intelligent analysis via Quick BI API 2. **Dataset Q&A**: Natural language queries on Quick BI platform datasets, with automatic intelligent table selection and matching 3. **Document Parsing**: Parse PDF/Word/Excel/CSV/images, extract text, and support extracting key fields to generate structured Excel 4. **Dashboard Skill Generation**: Auto-convert QuickBI dashboards into data query skills 5. **Data Insight**: Deep data insight analysis on Quick BI datasets 6. **Data Report**: Auto-generate professional data reports based on analysis results Use when users mention data analysis, smart Q&A, querying data, file analysis, document parsing, dashboard skills, data insight, or data reports. compatibility: "tools: [python3, pip, browser], runtime: [requests, pyyaml, matplotlib, numpy]" metadata: label: Quick BI-SmartQ version: "1.3.0" --- # Quick BI-SmartQ — QuickBI Data Analysis Assistant One entry point covering all QuickBI data analysis capabilities. Automatically routes to the corresponding module based on user intent — no manual selection required. ## Scope **Does:** - Automatically identify user intent and route to the corresponding data analysis module - Perform natural-language analysis on user-uploaded Excel/CSV files via the Quick BI API (File Q&A) - Perform natural-language query analysis on Quick BI platform datasets, with automatic intelligent table selection and matching (Dataset Q&A) - Parse PDF/Word/Excel/CSV/images, extract text, and support extracting key fields to generate structured Excel (Document Parsing) - Auto-convert QuickBI dashboards into data query skills (Dashboard Skill Generation) - Perform deep insight analysis on datasets (Data Insight) - Auto-generate professional data reports based on analysis results (Data Report) **Does NOT:** - Use pandas/openpyxl/csv or similar libraries to read files locally for analysis in Q&A scenarios - Require users to manually choose a module or provide internal parameters such as cubeId - Perform tasks unrelated to QuickBI data analysis ## Task Routing Automatically determine intent based on user input and route to the corresponding module for execution. ### Routing Decision Table | User Intent | Routed Module | Reference Document | |------------------------|--------------------------|------------------------------| | Uploaded Excel/CSV file, wants to query specific metrics or answer specific data questions (e.g. TOP N, comparison, filtering) | File Q&A | [module-chat-file.md](references/chat/module-chat-file.md) | | 上传了 Excel/CSV 文件，要查询具体指标或回答具体数据问题（如 TOP N、对比、筛选） | File Q&A | [module-chat-file.md](references/chat/module-chat-file.md) | | No file uploaded, wants to query/analyze specific metrics in platform datasets | Dataset Q&A | [module-chat-dataset.md](references/chat/module-chat-dataset.md) | | 未上传文件，要查询/分析平台数据集中的具体指标 | Dataset Q&A | [module-chat-dataset.md](references/chat/module-chat-dataset.md) | | Uploaded multiple files (PDF/Word/images etc.) or selected a folder, wants to query specific data questions (e.g. TOP N, comparison, filtering) | Document Parsing → File Q&A | [module-document-parser.md](references/document/module-document-parser.md) → [module-chat-file.md](references/chat/module-chat-file.md) | | 上传了多个文件(PDF/word/图片等)，或者选择文件夹，要查询具体数据问题（如 TOP N、对比、筛选） | Document Parsing → File Q&A | [module-document-parser.md](references/document/module-document-parser.md) → [module-chat-file.md](references/chat/module-chat-file.md) | | Uploaded PDF/Word/images or other unstructured documents, or selected a folder, wants to parse all file contents or extract fields | Document Parsing | [module-document-parser.md](references/document/module-document-parser.md) | | 上传了 PDF/Word/图片等非结构化文档，或者选择文件夹，要解析所有文件内容或提取字段 | Document Parsing | [module-document-parser.md](references/document/module-document-parser.md) | | Provided a QuickBI dashboard URL, wants to generate a query skill | Dashboard Skill Generation | [module-dashboard.md](references/dashboard/module-dashboard.md) | | 提供了 QuickBI 仪表板 URL，要生成查询技能 | Dashboard Skill Generation | [module-dashboard.md](references/dashboard/module-dashboard.md) | | Uploaded Excel file, wants deep interpretation/insight/trend analysis of data (not generating a report document) | Data Insight | [module-data-insight.md](references/insight/module-data-insight.md) | | 上传了 Excel 文件，要对数据进行深度解读/洞察/趋势分析（不生成报告文档） | Data Insight | [module-data-insight.md](references/insight/module-data-insight.md) | | Uploaded multiple files (PDF/Word/images etc.) or selected a folder, wants deep interpretation/insight/trend analysis of data (not generating a report document) | Document Parsing → Data Insight | [module-document-parser.md](references/document/module-document-parser.md) → [module-data-insight.md](references/insight/module-data-insight.md) | | 上传了多个文件(PDF/word/图片等)，或者选择文件夹，要对数据进行深度解读/洞察/趋势分析（不生成报告文档） | Document Parsing → Data Insight | [module-document-parser.md](references/document/module-document-parser.md) → [module-data-insight.md](references/insight/module-data-insight.md) | | Wants to generate a report/analysis report/review report, regardless of whether files are uploaded | Data Report | [module-data-report.md](references/report/module-data-report.md) | | 要生成报告/分析报告/复盘报告，无论是否上传文件 | Data Report | [module-data-report.md](references/report/module-data-report.md) | ### Routing Priority Rules When user intent may match multiple modules, determine by the following priority: 1. **"Report" keyword takes priority** (「报告」关键词优先): When user intent contains keywords like "report", "review", "summary report", "analysis report" (报告/复盘/总结报告/分析报告), **ALWAYS route to the Data Report module**, regardless of whether files are uploaded. Data Report module has higher priority than File Q&A and Data Insight. 2. **"Interpret", "insight", "trend" keywords** (「解读」「洞察」「趋势」关键词): When user wants to understand data meaning, discover trends, or gain insights (解读/洞察/趋势), route to the Data Insight module. 3. **Specific data query** (具体数据查询): When user wants to query specific metrics (TOP N, sum, comparison, etc.), route to the Q&A module (choose Dataset Q&A or File Q&A based on whether files are present). 4. **Dashboard URL** (仪表板 URL): When user provides a dashboard link, route to Dashboard Skill Generation. ### Routing Examples | User Input | Routing Result | Reasoning | |-------------------------------------|--------------------------------------------------------------|-------------------| | "Help me find the product with the highest sales in this data" + uploaded file | → File Q&A (module-chat-file) | Querying specific metric, has file | | "帮我查一下这份数据中销售额最高的产品" + 上传文件 | → File Q&A (module-chat-file) | 查具体指标，有文件 | | "Help me analyze this Excel data, TOP 10 headcount by department" + uploaded file | → File Q&A (module-chat-file) | Querying specific metric (TOP N), has file | | "帮我分析这份Excel数据，各部门人数分布TOP10" + 上传文件 | → File Q&A (module-chat-file) | 查具体指标（TOP N），有文件 | | "Top 3 regions with the highest sales" | → Dataset Q&A (module-chat-dataset) | Querying specific metric, no file | | "销量最高的地区TOP3" | → Dataset Q&A (module-chat-dataset) | 查具体指标，无文件 | | "Parse these contracts and summarize the information" + folder | → Document Parsing (module-document-parser) | | | "解析这些合同并汇总信息" + 文件夹 | → Document Parsing (module-document-parser) | | | "Convert this dashboard into a query skill" + URL | → Dashboard Skill Generation (module-dashboard) | Provided dashboard URL | | "把这个仪表板转化为查询技能" + URL | → Dashboard Skill Generation (module-dashboard) | 提供了仪表板 URL | | "Help me interpret the trend in sales data" + uploaded file | → Data Insight (module-data-insight) | Requests interpretation/insight, not a report | | "帮我解读一下销售数据的趋势" + 上传文件 | → Data Insight (module-data-insight) | 要求解读/洞察，非报告 | | "Any patterns and insights in this data" + uploaded file | → Data Insight (module-data-insight) | Requests insight analysis | | "这份数据有什么规律和洞察" + 上传文件 | → Data Insight (module-data-insight) | 要求洞察分析 | | "Generate a sales data report for this month" | → Data Report (module-data-report) | Contains "report" keyword | | "生成一份本月销售数据报告" | → Data Report (module-data-report) | 含「报告」关键词 | | "Help me generate an analysis report based on this Excel" + uploaded file | → Data Report (module-data-report) | Contains "report" keyword, file used as reference | | "帮我基于这份Excel生成一份分析报告" + 上传文件 | → Data Report (module-data-report) | 含「报告」关键词，文件作为参考资料 | | "Summarize these data, write a review report" + uploaded files | → Data Report (module-data-report) | Contains "review report" keyword | | "汇总这几份数据，写一份复盘报告" + 上传文件 | → Data Report (module-data-report) | 含「复盘报告」关键词 | | "Combine these files to generate a data analysis report" + uploaded files | → Data Report (module-data-report) | Contains "report" keyword | | "结合这些文件生成数据分析报告" + 上传文件 | → Data Report (module-data-report) | 含「报告」关键词 | | "Parse these 10 invoice PDFs, extract fields and generate Excel" + multiple files | → Document Parsing (module-document-parser) | Contains "extract fields" related keywords | | "解析这10个发票PDF，提取字段生成Excel" + 多文件 | → Document Parsing (module-document-parser) | 含"提取字段"等相关关键字 | | "Help me find the product with the highest sales in this data" + multiple files or folder | → Document Parsing → File Q&A (module-chat-file) | Querying specific metric, has multiple files | | "帮我查一下这份数据中销售额最高的产品" + 多个文件或者文件夹 | → Document Parsing → File Q&A (module-chat-file) | 查具体指标，有多个文件 | | "Any patterns and insights in these files" + multiple files or folder | → Document Parsing → Data Insight (module-data-insight) | Requests insight analysis | | "这些文件中的数据有什么规律和洞察" + 多个文件或者文件夹 | → Document Parsing → Data Insight (module-data-insight) | 要求洞察分析 | | "Summarize these data, write a review report" + ≤5 files | → Data Report (module-data-report) | Contains "review report" keyword | | "汇总这几份数据，写一份复盘报告" + 5个以内文件 | → Data Report (module-data-report) | 含「复盘报告」关键词 | | "Summarize these data, write a review report" + >5 files | → Document Parsing → Data Report (module-data-report) | Contains "review report" keyword | | "汇总这几份数据，写一份复盘报告" + >5个文件 | → Document Parsing → Data Report (module-data-report) | 含「复盘报告」关键词 | ### Fallback Rules - When intent is unclear, **default to Dataset Q&A** (module-chat-dataset) (当意图不明确时，默认路由到数据集问数) - If the user involves multiple modules at the same time (e.g. "analyze data and generate a report" ("分析数据并生成报告")), execute them in sequence - **Special scenario — multi-file preprocessing before Q&A** (特殊场景 — 多文件问数前置处理): - When user uploads ≥5 unstructured documents (PDF/Word/images, etc.) and requests analysis - **MUST execute Document Parsing first** (generate structured Excel) - **Then route to the corresponding functional module based on question intent** (perform intelligent analysis on the generated Excel) - Example: "Analyze these invoice data" + 10 PDFs → Document Parsing (generate Excel) → File Q&A (analyze Excel) - 示例："分析这些发票数据" + 10个PDF → 文档解析(生成Excel) → 文件问数(分析Excel) - If routing is incorrect, allow the user to manually specify the module (路由错误时允许用户手动指定模块) ## Configuration This skill uses a **layered configuration** architecture, separating user configuration from the skill package. **Skill package updates will NOT overwrite user configuration**. > **`<workspace-dir>` convention**: In this document, `<workspace-dir>` refers to the absolute path of the folder the user currently has open in the IDE / file manager. The Agent MUST confirm this path before the first operation by running a Python script with `os.getenv([REDACTED_SECRET])`. If the script returns nothing or empty, use the absolute path of the folder selected by the user. MUST NOT infer using `$PWD`, `$CWD`, or `Path.cwd()` or similar runtime variables. > > **`<skill-package-dir>` convention**: In this document, `<skill-package-dir>` refers to the root directory of this skill after installation (i.e. the directory containing this `SKILL.md` file). The Agent can infer it from the path of this file. ### Configuration Loading Priority (higher overrides lower) 1. **Environment variable** `ACCESS_TOKEN` (highest priority, suitable for container deployment) 2. **Workspace-level configuration** `<workspace-dir>/.qbi/smartq-chat/config.yaml` 3. **QBI global configuration** `~/.qbi/config.yaml` (shared by all skills) 4. **Default configuration** `default_config.yaml` inside the skill package (package defaults, updated with the package) `server_domain`, `api_key`, `api_secret`, and `user_token` can be placed in the workspace-level configuration or the global configuration. When both exist, the workspace-level configuration takes priority. ### Configuration Item Descriptions - **`server_domain`**: Quick BI service domain - **`api_key`** / **`api_secret`**: OpenAPI authentication key pair (if not configured, built-in defaults are used for trial mode) - **`user_token`**: Quick BI platform user ID; the Q&A interface requires `userId` (if not configured, it is registered automatically and written back) If `use_env_property: true` is enabled, the configuration can be overridden through the `qbi_api_key`, `qbi_api_secret`, `qbi_server_domain`, and `qbi_user_token` fields in the `ACCESS_TOKEN` environment variable JSON. ### Automatic Trial Credential Registration When neither `api_key` nor `api_secret` is configured (regardless of whether `user_token` exists), the script will: 1. If `user_token` is also not configured, print a friendly message informing the user that trial credentials will be registered automatically and trial mode will begin 2. Use built-in default credentials to populate `api_key` and `api_secret` 3. Automatically register a user based on the device's unique identifier and write the userId to the global configuration `~/.qbi/config.yaml` (not affected by skill package updates) > Note: `user_token` existing alone in the global configuration (from automatic trial registration) will NOT prevent trial credential population. ONLY when `api_key` or `api_secret` exists in an external configuration will the trial flow be skipped. Trial expiration is controlled by the server-side interface through error code `AE0579100004` — no local tracking is required. ### Custom Configuration Guidance When users want to use their own Quick BI account credentials (rather than trial credentials), sign in to the Quick BI console, click the avatar option **"Copy skill configuration with one click"**, as shown below: > Show the configuration screenshot to the user based on current locale: > - zh_CN: ![Copy Skill Config](references/common/copy_skill_config_zh.png) > - en_US: ![Copy Skill Config](references/common/copy_skill_config_en.png) After copying, paste the configuration to the Agent. The Agent will automatically write `server_domain`, `api_key`, `api_secret`, and `user_token` into the workspace-level configuration `<workspace-dir>/.qbi/smartq-chat/config.yaml` (and decide whether to sync to the global configuration based on the `save_global_property` switch). ## Agent Configuration Update Rules (Required Reading) **Zero-configuration initialization for new users**: If the user says "initialize configuration", "I am a new user", or similar, but **has NOT provided any specific configuration values**, there is no need to manually write anything to any configuration file. Tell the user to run Q&A directly — the system will automatically complete trial registration (see the **Automatic Trial Credential Registration** section above). ONLY apply the following write rules when the user **explicitly provides** specific configuration values. **Existing configuration protection rule**: Before writing, the Agent **MUST** first check whether the workspace-level configuration file `<workspace-dir>/.qbi/smartq-chat/config.yaml` already exists and contains valid configuration. If the file already exists and is non-empty, the Agent **MUST NOT** modify or overwrite any configuration items on its own, unless the user **explicitly expresses intent to update** (e.g. "update my configuration", "replace with this configuration", "change api_key to xxx", etc.). When existing configuration is found, inform the user that configuration already exists and ask whether to confirm overwriting. When the user provides any one or more of `api_key`, `api_secret`, `user_token`, or `server_domain`, and the above protection rule is satisfied, the Agent **MUST** use a file editing tool to directly modify the corresponding user configuration file and write the provided values into the matching fields. **Write location rules**: - `server_domain`, `api_key`, `api_secret`, `user_token` → **ALWAYS** write to the **workspace-level configuration** `<workspace-dir>/.qbi/smartq-chat/config.yaml` - Global configuration read/write is controlled by the `save_global_property` switch (default `true`): - If the switch is `false` → **MUST NOT read or write global configuration under any circumstance**, skip the global-configuration-related steps below - If the switch is `true` and the **global configuration** `~/.qbi/config.yaml` is empty or does not exist → also write to the global configuration - If the switch is `true` and the global configuration already contains content → write ONLY to the workspace-level configuration, then ask the user "Global configuration already exists. Do you want to sync the update?" and decide whether to write based on the user's reply **Procedure**: 1. Extract configuration key-value pairs from the user message (support common formats such as `key: value`, `key：value`, and `key=value`) 2. Use a file editing tool (such as search_replace) to write the configuration into the workspace-level configuration file 3. Read the `save_global_property` value in the configuration; if it is `false`, skip to step 5 4. Check whether the global configuration `~/.qbi/config.yaml` exists and is non-empty: - If it is empty or does not exist → also write to the global configuration - If it already contains content → ask the user "Global configuration already exists. Do you want to sync the update?" and decide whether to write based on the user's reply 5. After the update, confirm to the user which configuration items were written and where they were written **Prohibited actions**: - ❌ MUST NOT refuse to modify configuration citing reasons such as "limited permissions" or "unable to modify files inside the skill package" - ❌ MUST NOT suggest workarounds such as using environment variables or manually copying files - ❌ MUST NOT only output the configuration content and ask the user to modify it themselves ## Prerequisites - Python dependencies MUST be installed: `pip install requests pyyaml matplotlib numpy` - Browser automation capability is required (Dashboard Skill Generation module ONLY) - Dataset Q&A: user MUST have **Q&A permission** for the target dataset - File Q&A: file formats limited to `xls`, `xlsx`, `csv`; single file size ≤ 10MB - **Document Parsing**: - System dependency: `brew install tesseract tesseract-lang` (required for local parsing ONLY) - Supported formats: PDF, Word (.doc/.docx), Excel (.xls/.xlsx), CSV, images (.png/.jpg/.jpeg) - Single file size ≤ 10MB (remote OCR limit) - **Error handling**: - Local parsing failure → automatically falls back to remote OCR - Remote OCR still fails → classified as "parse failure", retaining original filename and error message - Unknown document type → extract 5+ generic fields, MUST obtain user confirmation before generating Excel - Detailed documentation: [module-document-parser.md](references/document/module-document-parser.md) ## Script Calling Convention (Required Reading) When calling any Python script: 1. The script path MUST use the **absolute path of the installed skill package directory** (i.e. `<skill-package-dir>/scripts/...`); MUST NOT use relative paths 2. **MUST** pass the absolute path of `<workspace-dir>` via the `--workspace-dir` parameter (see the conventions in the **Configuration** section above for how to obtain it) 3. Wrap path parameter values in quotes (to prevent shell tokenization issues caused by Chinese characters, spaces, or other special characters) 4. `smartq_stream_query.py`, `file_stream_query.py`, `q_insights.py`, `create_chat.py`, `generate_report.py` MUST include the `--locale` parameter — see **User Locale Determination Rules** below **Invocation examples**: ```bash # File upload python '<skill-package-dir>/scripts/chat/upload_file.py' '/path/to/data.xlsx' --workspace-dir '<workspace-dir>' # File Q&A python '<skill-package-dir>/scripts/chat/file_stream_query.py' <fileId> "各部门人数分布" --locale zh_CN --workspace-dir '<workspace-dir>' # Dataset Q&A python '<skill-package-dir>/scripts/chat/smartq_stream_query.py' "TOP 3 regions by sales" --locale zh_CN --workspace-dir '<workspace-dir>' # Dataset Q&A (with dataset name hint — enables name lookup, exact match skips intelligent table selection) python '<skill-package-dir>/scripts/chat/smartq_stream_query.py' "Based on 'Order Sales Details', what is the sales share by platform in Q1?" --cube-name 'Order Sales Details' --locale zh_CN --workspace-dir '<workspace-dir>' # Document Parsing - local python '<skill-package-dir>/scripts/document/document_local_parse.py' '/path/to/folder/' --json --workspace-dir '<workspace-dir>' # Document Parsing - remote OCR python '<skill-package-dir>/scripts/document/document_remote_ocr.py' '/path/to/folder/' --workspace-dir '<workspace-dir>' # Excel generation python '<skill-package-dir>/scripts/document/generate_excel.py' '<json-path>' --workspace-dir '<workspace-dir>' # Data Insight python '<skill-package-dir>/scripts/insight/q_insights.py' "这个报表有什么异常？" --excel-file '/path/to/data.xlsx' --locale zh_CN --workspace-dir '<workspace-dir>' # Report generation python '<skill-package-dir>/scripts/report/generate_report.py' "本月销售分析" --locale zh_CN --workspace-dir '<workspace-dir>' ``` ### User Locale Determination Rules > **Core principle**: `--locale` MUST be determined **SOLELY based on the user's input text**, NOT influenced by any other source. Valid values: `zh_CN` or `en_US` only. **Determination method**: - Examine the **user's original input message** (the question or instruction the user typed) - Identify the **question/instruction language** — the language of the sentence structure, verbs, and functional words (not embedded proper nouns) - Chinese question language → `zh_CN`; English or other question language → `en_US` **Mixed-language handling** (critical): - When user input contains both Chinese and English, determine locale by the **question framing language**, NOT by embedded entity names (dataset names, field names, table names, etc.) - Entity names (dataset names, field names, etc.) embedded in the question are **proper nouns / references** — they do NOT indicate the user's language preference - Rule of thumb: strip out quoted names or recognizable entity references, then judge the language of the remaining sentence structure **What counts as "user input text"**: - ✅ The text the user typed in the current conversation turn - ✅ The user's original question when performing follow-up queries in the same session **What MUST NOT influence locale determination**: - ❌ The Agent's own reply language (Agent may reply in a language different from the user's input) - ❌ API response content or error messages (these are always in a fixed language regardless of the user's language) - ❌ Script console output text - ❌ Dataset names, field names, or other metadata — whether returned by the platform OR embedded in the user's question as references - ❌ System prompt language or Agent configuration language **Example**: - User input: "帮我分析销售数据" → `--locale zh_CN` (question language is Chinese) - User input: "Analyze sales data" → `--locale en_US` (question language is English) - User input: "Analyze the 销售数据集" → `--locale en_US` (question language is English; "销售数据集" is a dataset name reference, not the question language) - User input: "Show me data from 2024年度报表" → `--locale en_US` (question language is English; "2024年度报表" is a dataset name) - User input: "帮我查一下 Sales Dataset 的数据" → `--locale zh_CN` (question language is Chinese; "Sales Dataset" is a dataset name) - User input: "帮我分析销售数据", but API returns English error message → `--locale zh_CN` (locale is determined by user input, NOT by API response) - Previous Agent reply was in English, user then types "查询TOP3" → `--locale zh_CN` (locale is determined by user input, NOT by Agent's previous reply) **Prohibited actions**: - ❌ MUST NOT omit the `--workspace-dir` parameter when calling scripts - ❌ MUST NOT use relative paths to call scripts (e.g. `python3 scripts/chat/...`) - ❌ MUST NOT use hard-coded paths or guessed paths - ❌ MUST NOT omit the `--locale` parameter when calling scripts that require it - ❌ MUST NOT determine `--locale` based on Agent's own output language or API/script return content

path: references/chat/module-chat.md
content: # 问数模块 (Chat Module)

> 配置说明请参见主文件的「配置」章节。

## Scope

**Does:**
- 对 Quick BI 平台已授权数据集进行自然语言查询分析（数据集问数）
- 对用户上传的 Excel/CSV 文件通过 Quick BI API 进行自然语言分析（文件问数）
- 自动智能选表匹配最合适的数据集，无需用户提供 cubeId
- 渲染 matplotlib 图表并输出可视化结果和分析结论

**Does NOT:**
- 在问数场景下使用 pandas/openpyxl/csv 等库直接读取文件进行本地分析
- 要求用户手动提供 cubeId 或其他内部参数

## 技能触发与模式选择

### 模式 A：数据集问数（无文件上传）
- 用户没有上传文件，要查询平台数据集 → **数据集问数**
- 触发词示例："问数""小Q问数""查下xx数据集""数据集提问""自然语言查询"

### 模式 B：文件问数（有文件上传）
- 用户上传了 Excel/CSV 文件并对数据提问 → **文件问数**
- 触发词示例："帮我分析这份数据""查询xx最多的TOP10""各部门销售额对比""分析下这个文件""文件问数"
- **执行方式**：严格按两步脚本执行（upload_file.py → file_stream_query.py），不得用其他方式读取或分析文件

## 前置条件

- 需安装 Python 依赖：`pip install requests pyyaml matplotlib numpy`
- 数据集问数：用户需要有目标数据集的**问数权限**
- 文件问数：文件格式限 `xls`、`xlsx`、`csv`，单文件大小 ≤ 10MB

---

## 模式 A — 数据集问数

对 Quick BI 平台上已授权的数据集进行自然语言查询。

### 工作流程

一步式执行，脚本内部自动完成完整的问数 → 取数 → 渲染流程：

```mermaid
flowchart LR
  input["用户问题"] --> hasCubeId{"已指定 cubeId?"}
  hasCubeId -- 是 --> streamQuery["SSE 流式问数"]
  hasCubeId -- 否 --> queryCubes["查询有权限的数据集"]
  queryCubes --> tableSearch["智能选表 POST /tableSearch\n（带 cubeIds 参数）"]
  tableSearch -- 匹配到 --> streamQuery
  tableSearch -- 未匹配 --> relevance["按文本相关性选择最相关数据集"]
  relevance --> streamQuery
  streamQuery --> parseSSE["实时解析 SSE 事件"]
  parseSSE --> reasoning["输出推理过程"]
  parseSSE --> olapResult["olapResult 事件\n（取数结果直接内联）"]
  olapResult --> chart["matplotlib 图表 或 Markdown 表格"]
  parseSSE --> conclusion["输出结论"]
```

> **等待预期**：问数分析通常需要 15~60 秒，复杂查询可能更久。建议在发起问数前告知用户正在分析中。

### 执行命令

**默认用法（自动智能选表，无需提供 cubeId）**：

```bash
python scripts/chat/smartq_stream_query.py "分析销售数据集中销量最高的地区TOP3"
```

> **cubeId 是可选参数**，脚本会自动查询用户有权限的数据集并通过智能选表匹配最合适的数据集，无需用户手动提供。

可选：已知目标数据集 ID 时直接指定（跳过智能选表）：

```bash
python scripts/chat/smartq_stream_query.py "总销售额是多少" --cube-id "dcbb0f94-4cee-4ba2-9950-927918bdd498"
```

可选：提供候选数据集列表辅助智能选表：

```bash
python scripts/chat/smartq_stream_query.py "总销售额是多少" --cube-ids "cubeId1,cubeId2,cubeId3"
```

### 内部处理流程

1. **智能选表**（当未指定 `--cube-id` 时自动触发）：
   - 调用 `GET /openapi/v2/smartq/query/llmCubeWithThemeList` 查询用户有权限的数据集列表
   - 按用户问题与数据集名称的文本相关性对所有权限数据集预排序
   - 使用**自适应降级策略**调用 `POST /openapi/v2/smartq/tableSearch` 进行智能选表：
     - 依次尝试批次大小 `[30, 10]`（可配置），取当前批次最相关的 top N 个数据集
     - 若接口返回 `"cubeIds can not be empty or over limit"` 错误，自动降级到下一批次
     - 传入参数：`userQuestion`、`userId`、`llmNameForInference`（默认 `SYSTEM_deepseek-r1-0528`）、`cubeIds`
     - 任意批次匹配成功即返回第一个 cubeId，不再继续尝试
   - 若所有批次均未匹配到结果，则按文本相关性从权限数据集中选取最相关的一个

2. **调用问数流式接口**：`POST /openapi/v2/smartq/queryByQuestionStream`，请求体为 JSON（`userQuestion`、`cubeId`、`userId` 等），响应为 SSE 事件流

3. **实时解析 SSE 事件**（事件格式：`event:message\ndata:{"data":"xxx","type":"xxx","subType":"xxx"}`）：
   - `relatedInfo` → 输出关联知识（数据集名称、业务定义等）
   - `reasoning` → 输出推理过程（subType `MODEL_REASONING` 为模型推理）
   - `text` / `sql` → 输出文本和 SQL 语句
   - `olapResult` → **核心步骤**，取数结果直接内联在事件流中
   - `summary` → 输出数据解读（subType `MODEL_REASONING` 为模型推理）
   - `conclusion` → 输出分析结论
   - `check` → 校验错误信息
   - `error` → 异常错误信息
   - `finish` → 问数结束

4. **olapResult 事件处理** ：
   - 从事件 `data` 中解析取数结果 JSON，包含 `values`（行数据）、`chartType`（图表类型枚举）、`metaType`（字段元信息）、`logicSql`（查询 SQL）
   - `metaType` 中 `t` 字段标识维度（dimension）或度量（measure），`type` 字段标识 row/column，多维度场景下 `colorLegend` 标识颜色图例维度
   - `chartType` 枚举：`NEW_TABLE`(交叉表) / `BAR`(柱图) / `LINE`(线图) / `PIE`(饼图) / `SCATTER_NEW`(散点图) / `INDICATOR_CARD`(指标看板) / `RANKING_LIST`(排行榜) / `DETAIL_TABLE`(明细表) / `MAP_COLOR_NEW`(色彩地图) / `PROGRESS_NEW`(进度条) / `FUNNEL_NEW`(漏斗图)
   - 将数据转换为 chart_renderer 格式并使用 matplotlib 渲染图表（输出到 `$WORKSPACE_DIR/output/` 目录）
   - matplotlib 不可用时回退为 Markdown 表格

### 输出说明

脚本运行时会实时输出以下内容：

- `[关联知识]` 命中的数据集和业务定义
- `[推理过程]` AI 的分析推理
- `[SQL]` 生成的查询 SQL
- `[取数结果]` 图表类型和取数状态
- **图表图片或 Markdown 表格**：取决于图表类型和渲染条件（详见下方「展示规则」）
- **`[图表数据]`**：所有图表的结构化数据（含字段信息、数据行、图表类型等）会保存到 JSON 文件，并在控制台输出文件路径
- `[结论]` 最终分析结论
- `[数据解读]` 对数据的进一步解读分析
- `[Trace]` 请求追踪 ID（问题反馈时提供此 ID 可加速排查）
- `[完成]` 问数结束

### 展示规则

> 并非所有问数结果都会生成图表图片。脚本会根据图表类型和渲染条件自动选择输出**图片**或 **Markdown 表格**，Agent 应根据脚本的实际输出格式进行回复。

**何时有图片**：脚本输出中包含 `[...](...)` 时，说明图表已渲染为 PNG。
**何时无图片**：以下场景脚本只输出 Markdown 表格或纯文字结论，不会有 `[...](...)` 图片：
- 图表类型为交叉表（`NEW_TABLE`）或明细表（`DETAIL_TABLE`）→ 直接输出 Markdown 表格
- matplotlib 未安装或渲染失败 → 回退为 Markdown 表格
- 取数结果为空（`values` 无数据）→ 仅输出 `[结论]` 和 `[数据解读]`
- 查询校验失败或出错 → 仅输出 `[校验]` 或 `[错误]` 信息

#### 有图片时（强制）

> **MUST**：脚本输出中包含 `[...](...)` 图片引用时，Agent 的答复中**必须原样包含**该 Markdown 图片语法，否则用户无法看到图表。这是硬性要求，不可省略。

1. **原样复制** `![标题](文件路径)` 到答复正文中，让用户直接看到可视化结果
2. 紧接图片下方标注图表文件路径，例如：`> 图表路径：$WORKSPACE_DIR/output/chart_xxx.png`
3. **不要**在图表上方添加「饼图如下」「脚本输出路径」之类的机械化引导文字，分析结论自然衔接即可
4. 如果有多张图表，按脚本输出顺序逐一内联展示

#### 无图片时

1. 如果脚本输出了 Markdown 表格，**直接展示表格**，结合 `[结论]` 和 `[数据解读]` 进行总结
2. 如果脚本既无图片也无表格（取数为空、查询失败等），基于 `[结论]` / `[错误]` / `[校验]` 信息向用户说明结果
3. **禁止**在没有图片输出时自行编造 `[...](...)` 图片语法或占位表格

**示例 A — 脚本输出包含图片时**：

假设脚本输出中包含：
```
![销量TOP3地区](/path/output/chart_1744123456_1.png)
```

Agent 回复应为：
```
根据分析结果，销量最高的三个地区如下：

![销量TOP3地区](/path/output/chart_1744123456_1.png)
> 图表路径：/path/output/chart_1744123456_1.png

从图表可以看出，华东地区以 XX 万的销量位居第一……
```

**示例 B — 脚本输出为 Markdown 表格时（无图片）**：

假设脚本输出中包含：
```
[取数结果] 图表类型: table (交叉表), 字段数: 3, 数据行数: 5

| 地区 | 销量 | 占比 |
|------|------|------|
| 华东 | 1200 | 35%  |
| 华南 | 980  | 28%  |
| 华北 | 750  | 22%  |
| 西南 | 320  | 9%   |
| 其他 | 210  | 6%   |

[结论] 华东地区销量最高，占总销量的 35%
[数据解读] 华东和华南两个地区合计占比超过 60%，是主要销售区域……
```

Agent 回复应为：
```
根据数据集的查询结果：

| 地区 | 销量 | 占比 |
|------|------|------|
| 华东 | 1200 | 35%  |
| 华南 | 980  | 28%  |
| 华北 | 750  | 22%  |
| 西南 | 320  | 9%   |
| 其他 | 210  | 6%   |

华东地区销量最高，占总销量的 35%。华东和华南两个地区合计占比超过 60%，是主要销售区域……
```

**示例 C — 取数结果为空或查询失败时**：

假设脚本输出：
```
[取数结果] 查询结果（无数据）
[结论] 未查询到符合条件的数据，建议调整查询条件后重试
```

Agent 回复应为：
```
本次查询未返回数据，可能是筛选条件过于严格或数据集中暂无匹配记录。建议您调整查询条件后重试。
```

---

## 模式 B — 文件问数

基于用户上传的 Excel/CSV 结构化数据文件，通过流式问数接口进行智能分析。

### 工作流程

严格按两步执行，每一步独立运行并输出完整结果。步骤 1 的输出（`fileId`）作为步骤 2 的输入。

**错误处理原则**：业务逻辑错误（权限不足、试用到期、格式不支持等）**必须立即终止整个流程**；网络类瞬态错误（超时、连接中断）可重试 1~2 次（建议指数退避），重试仍失败则终止。

```mermaid
flowchart LR
  file["用户数据文件\n(xls/xlsx/csv)"] --> autoReg{"user_token\n已配置?"}
  autoReg -- 否 --> register["自动注册用户\n回填 ~/.qbi/config.yaml"]
  autoReg -- 是 --> upload
  register -- 失败 --> abort["终止流程\n告知原因"]
  register -- 成功 --> upload["步骤1: upload_file.py\nPOST /copilot/parse"]
  upload -- 失败 --> abort
  upload -- 成功 --> fileId["输出 fileId\n+ 文件结构详情"]
  fileId --> stream["步骤2: file_stream_query.py\nPOST /smartq/queryByQuestionStreamByFile"]
  stream -- 失败 --> abort
  stream -- 成功 --> parseSSE["实时解析 SSE 事件流"]
  parseSSE --> reasoning["输出思考/推理过程"]
  parseSSE --> code["code 事件 → 拼接完整 Python 代码"]
  parseSSE --> result["result 事件 → 解析结构化数据"]
  result --> renderChart["matplotlib 渲染图表 PNG\n保存到 $WORKSPACE_DIR/output/"]
  parseSSE --> reporter["reporter 事件 → 分析报告"]
  parseSSE --> conclusion["输出结论/数据解读"]
  parseSSE --> finish["finish 事件 → 结束"]
```

### 步骤 1 — 上传文件获取 fileId

```bash
python scripts/chat/upload_file.py /path/to/data.xlsx
```

| 项目 | 说明 |
|------|------|
| 接口 | `POST /openapi/v2/copilot/parse` |
| Content-Type | `multipart/form-data` |
| 功能 | 上传文件并解析各 Sheet 结构详情 |

#### 请求参数

| 参数 | 类型 | 说明 |
|------|------|------|
| `file` | File | 上传的数据文件（multipart 文件域） |
| `fileName` | String | 文件名（如 `sales_data.xlsx`） |
| `tableConfigs[0].tableName` | String | 表名（默认取文件名去后缀） |
| `tableConfigs[0].tableType` | String | `excel` 或 `csv` |
| `isSave` | String | 固定 `false` |
| `fileId` | String | 留空（首次上传） |

#### 输出内容

脚本会输出：上传进度提示、`fileId`、完整的响应 JSON（含文件结构详情、各 Sheet 的列名和类型）。

> **关键输出**：从输出中提取 `fileId` 值，作为步骤 2 的第一个参数。

#### 错误处理

步骤 1 出现以下任一情况时，**立即终止整个流程，不得继续执行步骤 2**：
- 用户自动注册失败
- 文件上传失败（格式不支持、大小超限、服务端解析错误）
- 脚本以非零退出码结束

> **等待预期**：文件问数通常需要 15~60 秒，复杂分析可能需要数分钟（最长 10 分钟超时）。建议提前告知用户耐心等待。

### 步骤 2 — 基于 fileId 发起流式问数

```bash
python scripts/chat/file_stream_query.py <fileId> "用户的问题"
```

示例：

```bash
python scripts/chat/file_stream_query.py "abc123-def456" "各部门的销售额对比"
```

| 项目 | 说明 |
|------|------|
| 接口 | `POST /openapi/v2/smartq/queryByQuestionStreamByFile` |
| Content-Type | `application/json` |
| 响应格式 | SSE (Server-Sent Events) 事件流 |
| 超时时间 | 10 分钟（600 秒） |

#### SSE 核心事件类型

| 事件类型 | 输出标记 | 处理方式 |
|----------|----------|----------|
| `text` | （直接输出） | 实时拼接输出文本内容 |
| `reasoning` | （直接输出） | 实时输出 AI 思考推理过程 |
| `code` | （静默收集） | 静默拼接 → 流结束后保存到 `$WORKSPACE_DIR/.qbi/smartq-chat/output/` |
| `result` | `[取数结果]` | 解析结构化数据 → matplotlib 渲染图表 PNG |
| `reporter` | （直接输出） | 实时拼接分析报告文本 |
| `html` | `[HTML 图表]` | 仅保存原始 HTML 到 `$WORKSPACE_DIR/.qbi/smartq-chat/output/` |
| `html_result` | `[图表数据]` | 解析结构化数据，渲染图表 |
| `sql` | `[SQL]` | 输出生成的 SQL 语句 |
| `conclusion` | `[结论]` | 输出最终分析结论 |
| `summary` | `[数据解读]` | 输出数据解读分析 |
| `trace` | `[Trace]` | 请求追踪 ID，问题反馈时提供此 ID 可加速排查 |
| `finish` | `[完成]` | 标记事件流结束（终止事件） |
| `error` | `[错误]` | 输出错误信息（终止事件） |

### 展示规则

> 并非所有文件问数结果都会生成图表图片。脚本会根据渲染条件自动选择输出**图片**或 **Markdown 表格**，Agent 应根据脚本的实际输出格式进行回复。

#### 有图片时（强制）

> **MUST**：脚本输出中包含 `[...](...)` 图片引用时，Agent 的答复中**必须原样包含**该 Markdown 图片语法，否则用户无法看到图表。这是硬性要求，不可省略。

1. **原样复制** `![标题](文件路径)` 到答复正文中
2. 紧接图片下方标注图表文件路径
3. **不要**在图表上方添加机械化引导文字
4. 如果有多张图表，按顺序逐一内联展示

#### 无图片时

1. 如果脚本输出了 Markdown 表格，直接展示表格
2. 如果 matplotlib 不可用，基于 `result` 事件数据输出 Markdown 表格
3. 如果既无图片也无表格，基于 `conclusion` / `summary` / `reporter` 内容组织回复
4. **禁止**在没有图片输出时自行编造 `[...](...)` 图片语法或占位表格

### 结果总结要求

Agent 在答复用户时，必须同时满足以下两点：

1. **内联图表（优先）**：若脚本输出中包含 `![...](...)` 图片引用，**必须先原样复制**到答复正文中（参见上方「展示规则 › 有图片时」），确保用户能看到可视化结果
2. **文字总结**：基于脚本输出中的 `conclusion`（结论）和 `summary`（数据解读）内容，结合 `reporter`（分析报告）文本，对分析结果进行**重新组织和总结**

**禁止**向用户展示分析代码或代码文件路径（详见重要提示第 10 条）。

---

## 异常处理（必读）

脚本已内置以下三种异常的检测逻辑，会在控制台自动打印对应提示。Agent 应参考 `../common/error_messages.md` 中的提示文案向用户传达，可根据上下文适当调整措辞，但核心信息（链接、操作建议）不可省略。检测到任一异常时，**立即终止流程**。

### 1. 无数据集权限

**触发条件**：数据集问数模式下，脚本输出包含「您当前没有可用的问数数据集」
**检测位置**：`scripts/chat/cube_resolver.py` 权限查询
**处理方式**：提示用户没有可用数据集，建议尝试文件问数或开通服务。详细提示文案见 [error_messages.md](../common/error_messages.md)

**附加规则**：整个回复中**只展示一次**，不得重复。可自然询问用户是否改用文件问数。

### 2. 试用到期

**触发条件**：任何步骤的脚本输出或 API 响应中出现错误码 `AE0579100004`
**检测位置**：`scripts/common/utils.py` 中的 `check_trial_expired()`
**处理方式**：告知用户试用已到期，引导开通正式服务。详细提示文案见 [error_messages.md](../common/error_messages.md)

### 3. 数据文件解析失败

**触发条件**：文件问数模式下，脚本输出包含「数据文件解析失败」
**检测位置**：`scripts/chat/file_stream_query.py` 中的 `_on_error` 方法
**处理方式**：提示用户检查文件格式和内容后重试。详细提示文案见 [error_messages.md](../common/error_messages.md)

---

## 关键接口汇总

| 接口 | 方法 | Content-Type | 模式 | 说明 |
|------|------|-------------|------|------|
| `/openapi/v2/smartq/tableSearch` | POST | application/json | A | 智能选表，返回匹配的 cubeId 列表 |
| [REDACTED_SECRET] | GET | - | A | 查询用户有权限的问数数据集列表 |
| [REDACTED_SECRET] | POST | application/json | A | 数据集问数流式接口，返回 SSE（olapResult 事件直接包含取数结果） |
| `/openapi/v2/copilot/parse` | POST | multipart/form-data | B | 上传文件并解析结构，返回 fileId |
| [REDACTED_SECRET] | POST | application/json | B | 文件问数流式接口（SSE） |
| [REDACTED_SECRET] | GET | - | 通用 | 通过 accountName 查询用户是否在组织中 |
| [REDACTED_SECRET] | POST | application/json | 通用 | 添加用户到组织 |

---

## 重要提示

1. **文件问数必须走 API**：详见顶部「核心约束」，禁止使用 pandas/openpyxl 等库直接分析用户上传的文件
2. **模式选择**：根据用户是否上传了文件自动选择数据集问数或文件问数模式
3. **数据集问数无需 cubeId**：用户进行数据集问数时，**直接执行脚本**，不传 `--cube-id`，脚本会自动智能选表。**禁止**要求用户提供 cubeId 或提示 cubeId 为必传参数
4. **文件问数必须分步执行**：先执行步骤 1 上传文件获取 `fileId`，再执行步骤 2 传入 `fileId` 进行问数，不可跳过或合并
5. **错误处理**：业务逻辑错误（权限不足、试用到期等）必须立即终止整个流程；网络类瞬态错误（超时、连接中断）可重试 1~2 次后终止。向用户清晰说明报错原因，并提醒：「如需进一步帮助，请联系 Quick BI 产品服务同学获取支持。」
6. **流式超时**：默认超时 10 分钟（600 秒），复杂查询可能需要较长时间
7. **文件格式限制**：仅支持 `xls`、`xlsx`、`csv` 格式，单文件不超过 10MB
8. **userId 自动处理**：`user_token` 未配置时，脚本启动时即自动基于设备唯一标识生成 accountId，通过组织用户接口检查并注册用户，注册成功后将 userId 回写到全局配置 `~/.qbi/config.yaml`，后续调用不再重复注册
9. **图表展示（强制）**：PNG 文件保存在 `$WORKSPACE_DIR/output/` 目录中，脚本会以 `![标题](路径)` 格式输出。Agent **必须**将脚本输出的 `[...](...)` 原样复制到答复中，这是用户看到图表的唯一方式，不可省略
10. **禁止展示代码**：文件问数中 `code` 事件的 Python 代码仅静默保存，**禁止在答复中向用户展示代码内容或代码文件路径**
11. **禁止编造占位表格**：Agent **禁止**自行构造含「(数据见下方图表)」等占位符的 Markdown 表格或其他入空壳表格

---

## Examples

**Example 1: 数据集问数（自动智能选表）**

Input:
```
用户: "销量最高的地区TOP3是哪些"
```

Expected:
```bash
python scripts/chat/smartq_stream_query.py "销量最高的地区TOP3是哪些"
```
脚本自动智能选表匹配数据集，输出推理过程、图表（或 Markdown 表格）和分析结论。

Agent 回复示例（脚本输出含图片时，必须包含图片 Markdown）：
```
根据销售数据集的分析，销量最高的三个地区为：

![销量最高的地区TOP3是哪些](/path/output/chart_1744123456_1.png)
> 图表路径：/path/output/chart_1744123456_1.png

从图表可以看出：1. XX地区销量最高……
```

**Example 2: 文件问数（上传 Excel 分析）**

Input:
```
用户: 上传了 sales_data.xlsx，提问"各部门的销售额对比"
```

Expected:
```bash
# 步骤 1：上传文件获取 fileId
python scripts/chat/upload_file.py /path/to/sales_data.xlsx
# 输出 fileId=abc123-def456

# 步骤 2：基于 fileId 发起问数
python scripts/chat/file_stream_query.py "abc123-def456" "各部门的销售额对比"
```
分两步执行，Agent 基于结论和数据解读总结分析结果。若脚本输出了 `[...](...)` 图片，**必须原样复制到回复中**；若输出的是 Markdown 表格则直接展示表格；若无图表数据则基于结论文字回复。不展示代码。

**Example 3: 数据集问数（结果为交叉表，无图片）**

Input:
```
用户: "各月份的销售明细数据"
```

Expected:
```bash
python scripts/chat/smartq_stream_query.py "各月份的销售明细数据"
```
脚本输出交叉表类型数据时，直接输出 Markdown 表格而非图片。

Agent 回复示例（无图片时基于表格和结论回复）：
```
以下是各月份的销售明细：

| 月份 | 销售额 | 订单数 |
|------|--------|--------|
| 1月  | 150万  | 320    |
| 2月  | 128万  | 280    |
| 3月  | 175万  | 390    |

从数据来看，3月的销售额和订单数均为最高……
```


path: references/common/error_messages.md
content: # 异常提示文案

以下为各异常场景的用户提示文案。Agent 输出时**禁止**使用 `[text](url)` 链接语法，所有 URL 直接以纯文本形式内嵌在文案中。

## 1. 无数据集权限

> 您当前没有可用的问数数据集。
>
> 📂 **试试「文件问数」**
> 无需任何权限配置，上传 Excel/CSV 文件即可直接分析。
>
> 🚀 **0 元体验，限时加码**
> 现在上阿里云，将额外赠送 30 天全功能体验，解锁企业级安全管控与深度分析引擎，让 AI 洞察更准、更稳。点击下方链接，领取试用：
> https://www.aliyun.com/product/quickbi-smart?utm_content=g_1000411205
>
> 💬 点击下方链接，进入交流群获取最新资讯：
> https://at.umtrack.com/r4Tnme

## 2. 试用到期

> 小 Q 超级分析助理已陪伴您一周，我们看到您在通过 AI 寻找数据背后的真相，这很了不起。
>
> 🕙 **试用模式已结束**
> 授权到期后，动态分析将暂告一段落。
>
> 💡 **其实，您可以更轻松**
> 目前的"文件模式"仍需您手动搬运数据。让 AI 直连企业存量数据资产，实现分析结果自动更新？立即体验完整功能。
>
> 🚀 **0 元体验，限时加码**
> 现在上阿里云，将额外赠送 30 天全功能体验，解锁企业级安全管控与深度分析引擎，让 AI 洞察更准、更稳。点击下方链接，领取试用：
> https://www.aliyun.com/product/quickbi-smart?utm_content=g_1000411205
>
> 💬 点击下方链接，进入交流群获取最新资讯：
> https://at.umtrack.com/r4Tnme

## 3. 数据文件解析失败

> ⚠️ **数据文件解析失败**
> 当前问数的数据文件可能存在格式或内容问题，服务端多次重试执行均未成功。
>
> 💡 **建议排查**
> 请检查文件是否为标准的 Excel/CSV 格式，确认数据内容完整无损后重新上传。
>
> 💬 如仍无法解决，点击下方链接，进入交流群联系 Quick BI 产品服务同学获取支持：
> https://at.umtrack.com/r4Tnme


path: references/dashboard/module-dashboard-reference.md
content: # QuickBI 仪表板技能生成器 - 参考文档

> 本文档包含 SKILL.md 的详细参考内容，供深入了解使用。

## 分析框架匹配规则

### 框架匹配规则表

综合**指标语义 + 布局模式 + 联动关系**，匹配最适合的分析框架。

| 匹配规则（基于真实字段名称） | 分析框架 | 适用场景 | 核心公式/方法 |
|---------------------------|---------|---------|--------------|
| 包含"销售额/收入"+"成本"+"利润"+"毛利率/利润率" | **杜邦分析** | 财务指标分解，盈利能力分析 | ROE = 利润率 × 资产周转率 × 权益乘数 |
| 包含"获客/新增"+"激活"+"留存"+"转化"+"收入/付费" | **AARRR 海盗模型** | 互联网产品增长漏斗分析 | 各环节转化率优化 |
| 包含"最近购买时间"+"购买频次"+"消费金额" | **RFM 客户分析** | 客户价值分群，精准营销 | R×F×M 评分矩阵 |
| 包含"产品/商品"维度 + "市场份额/增长率" | **波士顿矩阵** | 产品组合策略分析 | 明星/现金牛/问题/瘦狗分类 |
| 包含"步骤/阶段/环节"维度 + "转化率/流失率" | **漏斗分析** | 流程优化，定位流失环节 | 各环节转化率 = 下一步/上一步 |
| 包含"目标值/计划值" + "实际值/完成值" | **目标达成分析** | KPI 完成度监控 | 达成率 = 实际值/目标值 × 100% |
| 包含"同期/去年同期" + "当期/本期" | **同环比分析** | 时间对比趋势分析 | 同比 = (本期-同期)/同期 × 100% |
| 包含"预算" + "实际/执行" | **预实对比分析** | 预算执行监控 | 预算执行率 = 实际/预算 × 100% |
| 包含"库存/存货" + "周转/动销" | **库存分析** | 库存健康度监控 | 周转率 = 销售成本/平均库存 |
| 包含"客单价" + "客户数/用户数" + "销售额" | **客户价值分析** | 客户贡献度分析 | 销售额 = 客户数 × 客单价 |
| 包含"曝光/展示" + "点击" + "转化/成交" | **营销漏斗分析** | 广告投放效果分析 | CTR/CVR 等转化指标 |
| 包含"人力/人数" + "产出/效率" | **人效分析** | 人力资源效能分析 | 人均产出 = 总产出/人数 |
| 以上都不匹配 | **L1-L4 金字塔** | 通用层级分析框架 | 概览→趋势→分解→明细 |

---

## 布局模式分析规则

### 布局模式识别

基于 `tileLayout` 位置信息推断仪表板的整体分析模式。

| 布局特征 | 布局模式 | 典型特点 | 推断的仪表板类型 |
|---------|---------|---------|----------------|
| 第一行有多个 indicator-card 类型组件 | **指标矩阵型** | 顶部密集指标卡阵列 | 监控型仪表板（强调 L1 概览） |
| 存在 line/bar 等趋势图表 | **核心图表型** | 有主次之分的焦点布局 | 分析型仪表板（强调 L2/L3） |
| 底部存在 common-table | **明细导向型** | 底部有明细表 | 运营型仪表板（强调 L4 追溯） |
| 同一行有多个相同类型组件 | **对比分析型** | 并列布局便于对比 | 多维对比分析 |
| 组件数量少（≤4） | **聚焦分析型** | 少而精的核心图表 | 专题分析仪表板 |

### 布局模式与分析框架的关联

| 布局模式 | 倾向的分析框架 | 置信度提升依据 |
|---------|--------------|---------------|
| 指标矩阵型 | 目标达成分析、同环比分析 | 多指标并列 → 关注指标对比 |
| 核心图表型 | 趋势分析、漏斗分析 | 大图表为主 → 关注过程变化 |
| 明细导向型 | L1-L4 金字塔 | 有明细表 → 需要追溯能力 |
| 对比分析型 | 杜邦分析、客户价值分析 | 并列布局 → 关注维度对比 |

---

## 层级归类规则

### L1-L4 层级判断

| 层级 | 类型特征 | 位置特征 |
|-----|---------|----------|
| **L1** | indicator-card/kpi/gauge | y ≤ 20（顶部）|
| **L2** | line/area/indicator-trend | 20 < y < 50，含 datetime 维度 |
| **L3** | bar/pie/ranking-list | 30 < y < 70，含分类维度 |
| **L4** | common-table | y > 50（底部）|

### 分析主题推断（基于图表类型）

| 图表类型 | 分析主题模式 |
|---------|-------------|
| indicator-card/kpi | "{度量}指标展示" |
| line/area | "{度量}时序趋势" |
| pie | "{维度}分布/占比" |
| bar | "{维度}对比分析" |
| ranking-list | "{维度}排行榜" |
| common-table | "{主题}明细查询" |

---

## 意图路由规则

### 用户问法模式匹配

| 用户问法模式 | 提取的意图 | 匹配目标 |
|-------------|-----------|----------|
| "XX是多少/有多少" | 查询单一指标 | L1 指标卡 |
| "XX趋势/走势/变化" | 趋势分析 | L2 折线图/趋势图 |
| "XX排行/TOP/最高/最低" | 排序分析 | L3 排行榜 |
| "XX分布/占比/构成" | 结构分析 | L3 饼图/柱图 |
| "各XX的YY" | 维度分解 | L3 分组图表 |
| "XX明细/详情/列表" | 明细查询 | L4 明细表 |
| "为什么XX下降/上升" | 归因分析 | L1→L2→L3 联合 |

---

## 业务逻辑推断规则

### 指标组合推断公式

| 指标组合 | 推断公式 |
|---------|----------|
| 销售额 + 成本 + 利润 | 利润 = 销售额 - 成本 |
| 销售额 + 销量 | 客单价 = 销售额 / 销量 |
| 目标值 + 实际值 | 达成率 = 实际 / 目标 × 100% |
| 本期 + 同期 | 同比增长率 = (本期-同期)/同期 × 100% |
| 本期 + 上期 | 环比增长率 = (本期-上期)/上期 × 100% |

---

## 工具函数说明

### quickbi_openapi.py 函数清单

| 函数名 | 用途 | 使用阶段 |
|--------|------|----------|
| `load_config(config_path=None)` | 加载配置（优先级：环境变量 > 工作目录级 > 全局 > 包内默认） | Step 1.0, Step 2.1 |
| `is_dataportal_url(url)` | 判断是否为数据门户 URL | Step 1.0 |
| `extract_dataportal_ids(url)` | 从数据门户 URL 提取 productId 和 menuId | Step 1.0 |
| `get_dataportal_page_id(...)` | 通过 OpenAPI 获取数据门户关联的仪表板 pageId | Step 1.0 |
| `extract_page_id(url)` | 从仪表板 URL 提取 pageId | Step 1.0 |
| `validate_and_prepare_dashboard(...)` | 仪表板预校验及预处理 | Step 1.0 |
| `get_dashboard_json(...)` | 获取仪表板完整 JSON 数据 | Step 2.1 |
| `query_openapi(...)` | 调用 SmartQ 查询接口 | 生成的 skill 查询阶段 |
| `get_dashboard_update_time(...)` | 查询仪表板更新时间 | 生成的 skill 启动校验 |

### get_dashboard_json.js 函数清单

| 函数名 | 用途 |
|--------|------|
| `parseDashboardJson(json)` | 解析仪表板原始 JSON，提取组件结构 |
| `analyzeLayout(charts)` | 基于 tileLayout 分析图表布局 |

### config_loader.py 函数清单

| 函数名 | 用途 |
|--------|------|
| `load_config()` | 四层配置加载（优先级：环境变量 > 工作目录级 > 全局 > 包内默认） |
| `persist_to_global_config(key, value)` | 写入全局配置 `~/.qbi/config.yaml` |

---

## 错误码参考

### 预校验接口错误码

| 错误码 | 含义 | 处理建议 |
|--------|------|----------|
| `AE0510000005` | 用户不在组织中 | 检查 user_token 是否正确 |
| `AE0510150002` | 没有仪表板访问权限 | 检查用户是否有该仪表板的访问权限 |
| `AE0510200000` | 没有数据集管理或者授权的权限 | 检查是否有数据集管理和问数配置权限 |
| `AE0581030022` | 未购买问数功能 | 确认已购买 SmartQ 问数功能 |
| `OE10010106` | API 未授权 | 检查 api_key/api_secret 配置 |
| `CONNECTION_ERROR` | 网络连接失败 | 检查网络和 server_domain 配置 |

### 数据门户接口错误码

| 错误码 | 含义 | 处理建议 |
|--------|------|----------|
| `NO_PAGE_ID` | 数据门户菜单未关联仪表板 | 检查门户菜单是否正确配置了仪表板页面 |
| `CONNECTION_ERROR` | 网络连接失败 | 检查网络和 server_domain 配置 |

---

## dashboardData 数据结构

Step 2.2 解析后返回的完整数据结构：

```typescript
{
  success: boolean;
  basicInfo: {
    name: string;           // 仪表板名称
    pageId: string;         // 页面ID
    workspaceId: string;    // 工作空间ID
    gmtModified: number;    // 最后修改时间（毫秒级时间戳），用于 skill_generated_at
  };
  queryControls: Array<{    // 查询控件列表
    componentId: string;
    internalId: string;
    needManualQuery: boolean;
    fields: Array<{
      labelName: string;
      componentType: string;  // datetime / enumSelect
      relatedGraphIds: string[];
    }>;
  }>;
  chartComponents: Array<{  // 图表组件列表
    componentId: string;
    componentName: string;
    sourceId: string;       // 数据集ID - 问数调用的关键
    dimensions: Array<{caption: string; pathId: string}>;
    measures: Array<{caption: string; aggregateType: string}>;
    drillFields: Array<{caption: string}>;
    tabInfo: object | null; // Tab 从属关系
  }>;
  tabComponents: Array<{    // Tab 组件列表
    componentId: string;
    tabs: Array<{id: string; title: string}>;
  }>;
  richTextComponents: Array<{textContent: string}>;
  layoutAnalysis: {rows: Array};  // 布局分析
}
```


path: references/dashboard/module-dashboard.md
content: ---
name: quickbi-smartq-dashboard
description: >
  根据 QuickBI 仪表板生成专用查询技能。
  当用户提供仪表板 URL 并希望创建查询技能时使用。
  触发关键词：生成技能、仪表板转 Skill。
---

# QuickBI 仪表板技能生成器

通过 OpenAPI 获取 QuickBI 仪表板数据，发现其图表组件、字段配置、查询控件、布局关系，提炼分析思路，生成一份可用于数据查询的 SKILL.md 文件。

## Scope

**Does:**
- 接收 QuickBI 仪表板 URL 或数据门户 URL，解析出 pageId
- 调用 OpenAPI 获取仪表板完整 JSON 结构
- 解析图表组件、查询控件、数据集、字段配置
- 分析布局模式，匹配适用的分析框架（L1-L4 金字塔或专业框架）
- 生成完整的查询技能 SKILL.md 文件并安装到技能中心

**Does NOT:**
- 不执行实际的数据查询（查询由生成的子 skill 负责）
- 不支持非 QuickBI 平台的仪表板
- 不处理需要特殊权限的仪表板（会在预校验阶段提示错误）
- **不在 `fetch_dashboard_data` 失败时尝试任何替代方案**（必须终止流程，禁止绕行）

## 触发场景

当用户提出以下类型的请求时使用此 Skill：
- "帮我把这个 QuickBI 仪表板转化为一个查询 Skill"
- "把这个看板变成一个可以查询数据的 Skill"
- "生成这个仪表板的查询技能"
- "提取这个仪表板的分析思路，生成 Skill"
- "为这个仪表板生成技能：{URL}"
- 用户提供了一个 类似 `https://bi.aliyun.com/dashboard/view/pc.htm?pageId=XXXXXXX` 格式的仪表板 URL，并希望创建查询能力
- 用户提供了一个数据门户页面 URL（格式如 `https://bi.aliyun.com/product/view.htm?module=dashboard&productId=xxx&menuId=yyy`），并希望创建查询能力

### 支持的 URL 格式

| URL 类型 | 路径特征 | 关键参数 | 处理方式 |
|----------|---------|---------|----------|
| **仪表板页面** | `/dashboard/view/pc.htm` | `pageId` | 直接提取 pageId |
| **数据门户页面** | `/product/view.htm` | `productId`, `menuId` | 通过 OpenAPI 获取关联的 pageId |

## 前置条件

- 需要有效的 API 凭证（用于调用 OpenAPI 获取仪表板数据）
- 配置说明请参见主文件的「配置」章节

---

## Phase 1: 输入收集与验证

### Step 1.0: 获取用户输入

从用户消息中提取：

1. **页面地址**（必需）：QuickBI 仪表板链接或数据门户链接
2. **技能名称**（可选）：生成的 skill 目录名（kebab-case 格式）
   - 如果用户指定了技能名称，直接使用（会覆盖同名技能）
   - 如果未指定，将在 Phase 2 发现仪表板标题后自动推导

---

## Phase 2: 仪表板数据获取与解析

### Step 2.1: 一站式获取仪表板数据

> **⚠️ 强制约束**：必须使用封装脚本，禁止自行拆分执行。

使用 `scripts/fetch_dashboard_data.py` 一站式完成：配置加载 → URL 解析 → 预校验 → 获取 JSON → 解析结构 → 获取数据集名称。

**[强制规则] 失败立即终止，禁止任何绕行**：

> ⛔ **绝对禁止**：当 `fetch_dashboard_data` 返回失败时，**禁止尝试任何替代方案**，包括但不限于：
> - ❌ 直接调用底层 API（如 `get_dashboard_json`）
> - ❌ 跳过预校验步骤
> - ❌ 尝试"其他方法"获取数据
> - ❌ 继续执行后续任何步骤
>
> **唯一正确的行为**：输出错误信息 → 终止流程 → 等待用户修正后重新触发

- 如果获取失败（`result["success"] == False`），**必须立即终止整个流程**
- **失败原因已在 `result["error"]` 中说明**，直接展示给用户即可
- **不要尝试"智能"地绕过错误**——预校验失败说明前置条件不满足，绕行只会导致后续步骤全部失败

```python
from dashboard.fetch_dashboard_data import fetch_dashboard_data

# 一站式获取（自动处理：配置加载、URL解析、预校验、获取JSON、解析、数据集名称）
result = fetch_dashboard_data(user_input_url)

if not result["success"]:
    print(f"获取失败: {result['error']}")
    # ⛔ 必须立即终止！禁止尝试其他方法，禁止继续执行任何后续步骤
    return  # 流程到此结束，等待用户修正后重新触发

# 提取结果
dashboardData = result["dashboardData"]      # 解析后的仪表板结构
datasetNameMap = result["datasetNameMap"]    # cubeId -> cubeName 映射
page_id = result["pageId"]                   # 仪表板 pageId
dashboard_url = result["dashboardUrl"]       # 标准仪表板预览页地址（用于生成 skill）

print(f"获取成功: {dashboardData['basicInfo']['name']}")
```

**脚本位置**：[scripts/fetch_dashboard_data.py](scripts/fetch_dashboard_data.py)

**执行后必须输出**（确认数据已获取）：

```markdown
---
## Step 2.1 执行结果

**执行状态**：{成功/失败}
**仪表板名称**：{dashboardData.basicInfo.name}
**仪表板URL**：{dashboard_url}（用于仪表板知识库的 URL 字段）
**pageId**：{page_id}
**gmtModified**：{dashboardData.basicInfo.gmtModified}（用于 SKILL_METADATA.skill_generated_at）
**图表组件数**：{dashboardData.chartComponents.length} 个
**查询控件数**：{dashboardData.queryControls.length} 个
**Tab组件数**：{dashboardData.tabComponents.length} 个

> ⚠️ **pageId 校验**：上方 pageId 值来自脚本返回的 `result["pageId"]`。
> 如果用户传入的是数据门户 URL（含 productId），pageId 与 productId **一定不同**。
> 后续 Phase 3 生成技能文件时，所有需要 pageId 的地方**必须使用此值**，禁止使用 URL 中的 productId。

**数据集清单**（去重）：
| 数据集名称 | 数据集ID |
|-----------|----------|
| {datasetNameMap[cubeId]} | {cubeId} |

> 数据已存储到 `dashboardData`、`datasetNameMap` 和 `dashboard_url`，继续执行 Step 2.2
---
```

**失败处理**（⛔ 禁止绕行）：
- 如果 `result["success"] == False`，**立即终止整个流程**
- 输出错误信息 `result["error"]`，告知用户失败原因
- **禁止**尝试直接调用 `get_dashboard_json` 或任何其他方法
- 提示用户检查配置或仪表板权限后重新触发

**返回数据结构**：`dashboardData` 包含 `basicInfo`、`queryControls`、`chartComponents`、`tabComponents`、`richTextComponents`、`layoutAnalysis` 等字段，完整定义见 [reference.md - dashboardData 数据结构](./reference.md#dashboarddata-数据结构)。

### Step 2.2: 数据验证与补充

**目的**：验证解析结果的完整性，必要时补充信息。

#### 2.2.1 Tab 结构验证

如果 `dashboardData.tabComponents.length > 0`：

1. 列出所有 Tab 及其标题
2. 确认每个 Tab 下包含的图表组件
3. 记录 Tab 与图表的从属关系

#### 2.2.2 图表标题验证

1. 检查 `dashboardData.chartComponents[].componentName` 是否有意义
2. 如果为空或无意义，根据度量/维度字段推断主题
3. 记录调整后的图表标题

#### 2.2.3 富文本内容提取

1. 从 `dashboardData.richTextComponents[].textContent` 提取纯文本
2. 用于理解仪表板的业务背景和使用说明

### Step 2.3: 提炼分析思路与匹配分析框架

> **【必须执行步骤】** 完成 5 个子步骤 + 强制输出（2.3.2-OUTPUT）。
> 这是将仪表板数据转化为可用分析框架的核心步骤。即使时间紧迫，也必须完成此步骤的所有子步骤（2.3.1 - 2.3.5）和强制输出（2.3.2-OUTPUT）。

> **核心原则**：所有内容必须基于 `dashboardData`（Step 2.1 获取的数据）推断，不可臆造。

#### 2.3.1 数据提取

从 `dashboardData.chartComponents` 中提取：

**数据集清单**：收集所有图表的 `sourceId`，去重后建立清单：

| 数据集ID | 关联图表 | 可用维度 | 可用度量 |
|----------|---------|---------|----------|
| {sourceId} | {图表名列表} | {维度字段} | {度量字段(聚合方式)} |

**指标体系**：遍历 `chartComponents[].measures`，按 `caption` 去重。

**维度体系**：遍历 `chartComponents[].dimensions`，按 `itemType` 分类：
- `datetime` → 时间维度 | `geographic` → 地理维度 | `dimension` → 分类维度

#### 2.3.2 分析框架匹配

综合**指标语义 + 布局模式 + 联动关系**，匹配最适合的分析框架。

**框架匹配规则**：详见 [reference.md - 分析框架匹配规则](reference.md#分析框架匹配规则)

常用框架：杜邦分析、AARRR 海盗模型、RFM 客户分析、漏斗分析、目标达成分析、同环比分析等。无法匹配时使用 **L1-L4 金字塔**（概览→趋势→分解→明细）。

#### 2.3.2-OUTPUT: 【强制】输出分析框架匹配结果

> **不可跳过**：完成 2.3.1-2.3.2 后，**必须输出**以下格式：

```markdown
---
## 【分析框架匹配结果】

### 提取到的真实字段
**度量字段**：{measures 列表}
**维度字段**：时间({datetime}) | 地理({geographic}) | 分类({dimension})

### 布局模式
**布局特征**：第一行{N}个{类型}组件，总{M}行，底部{有/无}明细表
**布局模式**：{指标矩阵型/明细导向型/对比分析型/聚焦分析型}

### 框架匹配
**匹配框架**：{框架名称}
**置信度**：{high/medium/default}
**匹配依据**：{指标特征} + {布局特征} + {联动特征}

### 层级预览（仅 L1-L4 框架）
| 层级 | 图表数 | 典型图表 | 归类依据 |
|-----|-------|---------|----------|
| L1 | {N} | {示例} | {类型+位置} |
---
```

#### 2.3.3 布局模式分析与仪表板类型推断

基于 `tileLayout` 位置信息推断仪表板的整体分析模式。详见 [reference.md - 布局模式分析规则](reference.md#布局模式分析规则)

**布局模式类型**：指标矩阵型、核心图表型、明细导向型、对比分析型、聚焦分析型。

#### 2.3.4 自动匹配分析框架（多维度综合推断）

> **综合指标语义 + 布局模式 + 联动关系，匹配最适合的分析框架**
> 
> **重要**：这是一个**思考推断步骤**，综合多个维度的信息来推断分析框架。

**层级归类规则**：详见 [reference.md - 层级归类规则](reference.md#层级归类规则)

- **L1**（整体监控）：indicator-card/kpi/gauge，顶部位置
- **L2**（趋势分析）：line/area/indicator-trend，含 datetime 维度
- **L3**（维度分解）：bar/pie/ranking-list，含分类维度
- **L4**（明细追踪）：common-table，底部位置

**匹配思考流程**：

1. **列出所有真实指标**：从 `uniqueMeasures` 中列出所有指标名称
2. **列出所有真实维度**：从 `uniqueDimensions` 中列出所有维度名称
3. **识别布局模式**：根据布局特征判断仪表板类型
4. **分析联动关系**：哪些图表共享筛选器？暗示它们在同一分析路径上
5. **分析下钻方向**：`drillFields` 的维度类型暗示分析深入的方向
6. **综合语义分析**：结合以上信息，理解仪表板的整体分析意图
7. **框架匹配**：根据综合特征判断最匹配的分析框架
8. **记录匹配依据**：说明是基于哪些特征（指标+布局+联动）匹配到该框架

#### 2.3.5 业务逻辑推断

**业务逻辑推断（基于指标组合）**：

| 指标组合 | 推断公式 |
|---------|----------|
| 销售额 + 成本 + 利润 | 利润 = 销售额 - 成本 |
| 销售额 + 销量 | 客单价 = 销售额 / 销量 |
| 目标值 + 实际值 | 达成率 = 实际 / 目标 × 100% |

更多推断规则详见 [reference.md - 业务逻辑推断规则](reference.md#业务逻辑推断规则)

### Step 2.4: 推断意图路由矩阵

> **核心目标**：建立用户问题 → 目标图表 → 数据集ID 的精准映射

**意图关键词提取规则**：

| 用户问法模式 | 提取的意图 | 匹配目标 |
|-------------|-----------|----------|
| "XX是多少/有多少" | 查询单一指标 | L1 指标卡 |
| "XX趋势/走势/变化" | 趋势分析 | L2 折线图/趋势图 |
| "XX排行/TOP/最高/最低" | 排序分析 | L3 排行榜 |
| "XX分布/占比/构成" | 结构分析 | L3 饼图/柱图 |
| "各XX的YY" | 维度分解 | L3 分组图表 |
| "XX明细/详情/列表" | 明细查询 | L4 明细表 |
| "为什么XX下降/上升" | 归因分析 | L1→L2→L3 联合 |

更多规则详见 [reference.md - 意图路由规则](reference.md#意图路由规则)

### Step 2.5: 汇总探索结果

> **【前置检查】确认 Step 2.1 和 Step 2.3.2-OUTPUT 已输出分析框架匹配结果**

整理所有探查结果，按以下结构输出（详细格式见 Phase 3.2 模板），必须确保：
- 所有图表组件都被列出（包含数据集ID、字段列表、分析主题、层级归属）
- 分析框架匹配结果基于真实提取的指标和维度
- 业务背景综合仪表板标题、图表标题、字段名称、富文本内容
```
## 探索结果汇总
├── 基本信息（名称/pageId/URL）
├── 业务背景与统计口径
├── 分析框架匹配结果
├── 核心指标体系
├── 维度体系
├── 业务逻辑推断
├── 层级结构（A/B/C 形式）
├── 数据集清单
├── 查询控件
├── 图表组件完整列表（12列）
├── 意图路由矩阵
├── 联动与下钻路径
├── 下钻字段配置
└── 适用场景与查询路径
```

**关键要求**：
- 所有图表组件必须完整列出，每个都包含数据集ID、字段列表、分析主题
- 分析框架匹配结果基于真实提取的指标和维度
- 层级归属基于图表类型和布局位置

---

## Phase 3: 技能文件生成

根据探索结果，组装并写入 SKILL.md 和 config.yaml 文件。

> ⚠️ **关键变量确认**：生成技能文件前，确认以下值的来源：
> - **pageId** = `result["pageId"]`（Step 2.1 脚本返回值）—— **不是**用户输入 URL 中的 `productId`
> - **dashboard_url** = `result["dashboardUrl"]`（Step 2.1 脚本返回值，已包含正确 pageId）
> - **skill_generated_at** = `dashboardData.basicInfo.gmtModified`
>
> 数据门户 URL 中的 `productId` 是门户 ID，与仪表板 `pageId` 是完全不同的值，禁止混用。

### Step 3.1: 确定技能名称

如果用户提供了技能名称，直接使用（会覆盖同名技能）。否则：
1. 取仪表板标题
2. 转换为 kebab-case 格式（中文用拼音或有意义的英文缩写）
3. 添加 `qbi-` 前缀
4. **追加 pageId 前8位确保唯一性**
5. 例如："好美家零售数据" (pageId: `ab12cd34-xxxx`) → `qbi-retail-query-ab12cd34`

### Step 3.2: 组装 SKILL.md 内容

按以下模板生成技能文件。**{占位符}** 表示用探索结果填充的内容。

#### 3.2.1-3.2.7 内容模板

> **【必须执行】读取模板文件**
>
> 使用 `read_file` 工具读取 `./templates/output_skill_template.md` 文件，获取以下内容的完整模板：
> - **YAML Frontmatter + 技能元数据**（3.2.1）：name/description 格式、SKILL_METADATA 注释块
> - **标题和触发场景**（3.2.2-3.2.3）：5-8 个自然语言查询示例
> - **前置条件**（3.2.4）：配置引导流程（与本 skill 的前置条件逻辑一致）
> - **仪表板知识库**（3.2.5）：基本信息、业务背景、数据集清单、查询控件、图表组件完整列表、意图路由矩阵
> - **仪表板分析思路**（3.2.6）：分析框架匹配、层级结构、核心指标、维度体系、业务逻辑、联动路径
> - **工作流程**（3.2.7）：问题理解→拆解→构建查询→调用 SmartQ→汇总结果→错误处理
>
> 按模板格式生成对应内容，填充 `dashboardData` 中提取的真实数据。

**关键要求**：
- description 必须保留 `INSTEAD OF generic quickbi-smartq-chat` 优先级声明
- 所有图表组件必须完整列出，不遗漏
- 指标和维度必须来自 `dashboardData`，不可臆造

### Step 3.3: 生成 config.yaml

复制配置模板，敏感字段置空：

```yaml
# Quick BI 文件问数配置文件

# QBI 域名
server_domain: https://bi.aliyun.com

# OpenAPI 认证配置
[REDACTED_SECRET]

# 用户令牌
user_token:

# 是否从环境变量读取认证信息
use_env_property: false
```

### Step 3.4: 写入文件
1. **确定输出目录**：与当前技能所在目录保持一致
   - 获取当前技能的目录路径（即本 SKILL.md 文件所在的父目录的父目录）
   - 在该目录下创建 `{skill-name}/` 子目录
   - 例如：如果当前技能在 `.qoderwork/skills/quickbi-smartq-chat/`，则新技能应在 `.qoderwork/skills/{skill-name}/`
2. 创建目录（如不存在）
3. 写入 SKILL.md 文件
4. 写入 config.yaml 模板
5. **复制脚本文件到生成的 skill 的 scripts 目录**（需调整 import）：
   - 复制 `scripts/dashboard/quickbi_openapi.py` → `{skill-name}/scripts/quickbi_openapi.py`
     - **必须调整 import**：移除 `sys.path.insert(0, ...)` 行，将 `from common.config_loader import load_config as _load_config_from_loader` 改为 `from config_loader import load_config as _load_config_from_loader`（注意：不要加点号前缀，因为 scripts 目录不是 Python 包，脚本通过 `sys.path.insert` 方式加载，只能使用绝对导入）
   - 复制 `scripts/common/config_loader.py` → `{skill-name}/scripts/config_loader.py`
     - **必须调整**：将 `DEFAULT_CONFIG_PATH = BASE_DIR.parent.parent / "default_config.yaml"` 改为 `DEFAULT_CONFIG_PATH = BASE_DIR.parent / "config.yaml"`（扁平结构下 `BASE_DIR` 指向 `scripts/`，`BASE_DIR.parent` 即 skill 根目录）
6. **复制 `references/common/copy_skill_config.png`** 到生成的 skill 的 `example/` 目录，用于首次配置引导
7. 告知用户：
   - 技能文件已生成
   - 首次使用时会引导配置 API 凭证（config.yaml）
   - 生成的技能如何使用
8. **将生成的技能安装到技能中心**（必须执行）：
   执行以下命令将技能注册到技能中心：
   ```bash
   skills install local --json '{"sourcePath": "<生成的 skill 目录绝对路径>"}'
   ```

**生成的 Skill 目录结构**：
```
./skills/{skill-name}/
├── SKILL.md           # 技能文件
├── config.yaml        # API 配置（首次使用时会引导用户配置）
├── example/
│   └── copy_skill_config.png  # 首次配置引导图片（来源：common/copy_skill_config.png）
└── scripts/
    ├── quickbi_openapi.py  # OpenAPI 调用工具函数
    └── config_loader.py    # 配置加载器（全局配置存在即用）
```
---

## Examples

### Example 1: 从仪表板 URL 生成查询技能

**Input:**
```
用户：帮我把这个仪表板转成查询技能
https://bi.aliyun.com/dashboard/view/pc.htm?pageId=ab12cd34-5678-90ef-ghij-klmnopqrstuv
```

**Expected Output:**
1. 执行预校验，确认用户有访问权限
2. 获取仪表板 JSON 并解析组件结构
3. 输出分析框架匹配结果（如 L1-L4 金字塔）
4. 生成 `skills/qbi-xxx-ab12cd34/SKILL.md`
5. 自动安装到技能中心

### Example 2: 从数据门户 URL 生成查询技能

**Input:**
```
用户：这是我们的数据门户，生成一个可以查数据的 skill
https://bi.aliyun.com/product/view.htm?module=dashboard&productId=abc123&menuId=menu456
```

**Expected Output:**
1. 识别为数据门户 URL，调用 `get_dataportal_page_id` 获取关联的仪表板 pageId
2. 执行后续标准流程（同 Example 1）

---

## 重要注意事项

1. **API 凭证安全**：config.yaml 中的 AccessKey 是敏感信息，提醒用户妥善保管
2. **数据集ID是关键**：问数查询依赖正确的 `sourceId`（数据集ID），必须从 JSON 中准确提取
3. **意图路由准确性**：意图路由矩阵决定了用户问题能否正确匹配到数据集，需要仔细推断
4. **分析框架基于真实数据**：所有分析框架的指标和维度必须来自 `dashboardData`（Step 2.1 获取的数据），不可臆造
5. **分析框架必须输出**：Step 2.3.2-OUTPUT 是强制步骤，必须在汇总探索结果前输出分析框架匹配结果。如果跳过此步骤，生成的 Skill 将缺少核心分析能力

---

## 附录 C: 工具函数

### 目录结构

```
quickbi-smartq-chat/
├── SKILL.md                       # 统一入口技能
├── default_config.yaml            # 默认配置
├── references/
│   ├── dashboard/
│   │   ├── module-dashboard.md        # 本文档
│   │   ├── module-dashboard-reference.md  # 详细参考文档
│   │   └── templates/
│   │       └── output_skill_template.md  # 生成模板
│   └── common/
│       └── copy_skill_config.png      # 配置引导图片
└── scripts/
    ├── common/
    │   └── config_loader.py           # 配置加载器（四层配置优先级）
    └── dashboard/
        ├── fetch_dashboard_data.py    # 一站式仪表板数据获取
        ├── get_dashboard_json.js      # JSON 解析脚本
        └── quickbi_openapi.py         # OpenAPI 工具函数
```

### 核心函数

#### fetch_dashboard_data.py

| 函数名 | 用途 | 使用阶段 |
|--------|------|----------|
| `fetch_dashboard_data(url, config=None)` | 一站式获取仪表板数据（配置加载+URL解析+预校验+获取JSON+解析+数据集名称） | Step 2.1 |

#### quickbi_openapi.py

| 函数名 | 用途 | 使用阶段 |
|--------|------|----------|
| `load_config(config_path=None)` | 加载配置（优先级：环境变量 > 工作目录级 > 全局 > 包内默认） | Step 1.0 |
| `is_dataportal_url(url)` | 判断是否为数据门户 URL | Step 1.0 |
| `extract_dataportal_ids(url)` | 从数据门户 URL 提取 productId 和 menuId | Step 1.0 |
| `get_dataportal_page_id(...)` | 通过 OpenAPI 获取数据门户关联的仪表板 pageId | Step 1.0 |
| `extract_page_id(url)` | 从仪表板 URL 提取 pageId | Step 1.0 |
| `validate_and_prepare_dashboard(...)` | 仪表板预校验及预处理 | Step 1.0 |
| `get_dashboard_json(...)` | 获取仪表板完整 JSON 数据 | 内部调用 |
| `batch_get_dataset_schema(...)` | 批量获取数据集详情（名称等） | 内部调用 |
| `query_openapi(...)` | 调用 SmartQ 查询接口 | 生成的 skill 查询阶段 |
| `get_dashboard_update_time(...)` | 查询仪表板更新时间 | 生成的 skill 启动校验 |

#### get_dashboard_json.js

| 函数名 | 用途 |
|--------|------|
| `parseDashboardJson(json)` | 解析仪表板原始 JSON，提取组件结构 |
| `analyzeLayout(charts)` | 基于 tileLayout 分析图表布局 |

#### config_loader.py（scripts/common/）

| 函数名 | 用途 |
|--------|------|
| `load_config()` | 四层配置加载（优先级：环境变量 > 工作目录级 > 全局 > 包内默认） |
| `check_trial_expired(result)` | 检查 API 返回结果是否为试用过期错误 |
| `get_server_domain(config=None)` | 获取 server_domain（可选传入已加载的 config） |
| `persist_to_global_config(key, value)` | 写入全局配置 `~/.qbi/config.yaml` |
| `persist_to_skill_config(key, value)` | 写入工作目录级配置 `$WORKSPACE_DIR/.qbi/smartq-chat/config.yaml` |

### 预校验接口错误码

| 错误码 | 含义 | 处理建议 |
|--------|------|----------|
| `AE0510000005` | 用户不在组织中 | 检查 user_token 是否正确 |
| `AE0510150002` | 没有仪表板访问权限 | 检查用户是否有该仪表板的访问权限 |
| `AE0510200000` | 没有数据集管理或者授权的权限 | 检查是否有数据集管理和问数配置权限 |
| `AE0581030022` | 未购买问数功能 | 确认已购买 SmartQ 问数功能 |
| `OE10010106` | API 未授权 | 检查 api_key/api_secret 配置 |
| `CONNECTION_ERROR` | 网络连接失败 | 检查网络和 server_domain 配置 |

### 数据门户接口错误码

| 错误码 | 含义 | 处理建议 |
|--------|------|----------|
| `NO_PAGE_ID` | 数据门户菜单未关联仪表板 | 检查门户菜单是否正确配置了仪表板页面 |
| `CONNECTION_ERROR` | 网络连接失败 | 检查网络和 server_domain 配置 |


path: references/dashboard/templates/output_skill_template.md
content: # 输出 SKILL.md 模板

> 本文件包含生成 SKILL.md 的完整模板，供 Step 3.2 引用。

## YAML Frontmatter

```yaml
---
name: {skill-name}
description: >
  **专用查询技能** for "{仪表板标题}" QuickBI dashboard.
  Use this skill INSTEAD OF generic quickbi-smartq-chat when user asks about:
  {5-8个核心度量字段，逗号分隔}.
  Trigger keywords: {仪表板标题/简称}, {3-5个业务主题词}.
---
```

> **生成要点**：
> - 只保留**高区分度**的关键词，不要列出所有字段
> - 核心度量：选 5-8 个用户最常问的指标（如销售额、订单数、库存数量）
> - 业务主题：选 3-5 个该仪表板独有的主题词（如销售分析、供应链、客户服务）
> - `INSTEAD OF generic quickbi-smartq-chat` 是优先级声明，必须保留

## 技能元数据

在 YAML frontmatter 后面添加元数据块，用于支持自动更新校验：

```markdown
<!-- SKILL_METADATA
dashboard_page_id: {pageId}
skill_generated_at: {生成时的 gmtModified 值}
dashboard_name: {仪表板标题}
generator_skill: quickbi-smartq-chat
-->
```

> **字段说明**：
> - `dashboard_page_id`：仪表板的 pageId，用于查询更新时间
> - `skill_generated_at`：**直接取 `dashboardData.basicInfo.gmtModified`（Step 2.1 已返回）**
> - `dashboard_name`：仪表板名称，用于用户提示
> - `generator_skill`：生成此技能的元技能名称，用于触发重新生成

## 标题和描述

```markdown
# {仪表板标题} - 数据查询

针对「{仪表板标题}」仪表板的专用查询技能，支持自然语言查询仪表板相关的数据。
```

## 入口检查

> **【关键】此部分位于 SKILL.md 开头，确保 LLM 在处理任何用户问题前先执行更新检测**

```markdown
## ⚠️ 入口检查（每次查询必须执行）

> **强制约束**：当用户提出任何问题时，**必须首先执行此检查**，然后才能执行后续步骤。

**执行步骤**：
1. 运行以下代码检查仪表板是否有更新：
\`\`\`python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath('__file__')), 'scripts'))
from config_loader import load_config
from quickbi_openapi import get_dashboard_update_time
config = load_config()
result = get_dashboard_update_time(host=config["server_domain"], access_id=config["api_key"], access_key=config["api_secret"], page_id="{pageId}", user_id=config["user_token"])
has_update = result["success"] and result["data"]["last_modified"] > {skill_generated_at}
print(f"has_update: {has_update}")
\`\`\`

> **注意**：`'__file__'` 占位符在生成时必须替换为生成的 SKILL.md 所在目录的绝对路径（即 `<技能包目录>`）

2. **根据结果处理**：
   - `has_update == False` → 静默继续，执行后续查询
   - `has_update == True` → **立即终止当前 skill**，**原样输出**以下提示（URL 用反引号包裹避免渲染为链接）：

检测到「{仪表板名称}」仪表板已更新，当前技能的数据结构可能已过时。

请按以下步骤重新生成技能：（复制下面内容到对话框执行）

第 1 步：删除当前技能
删除 {skill-name} 技能

第 2 步：重新生成技能
为这个仪表板生成技能：`{仪表板URL}`

> **LLM 执行约束**：输出上述提示后立即终止，**禁止**自动删除技能或自动重新生成，必须等待用户手动执行。

---
```

> **生成要点**：
> - `{pageId}` 替换为 Step 2.1 返回的 `result["pageId"]`
>   - ⛔ **禁止**使用用户输入 URL 中的 `productId`（门户 ID ≠ 仪表板 pageId）
>   - 可通过 Step 2.1 输出的「pageId 校验」区块确认正确值
> - `{skill_generated_at}` 替换为 `dashboardData.basicInfo.gmtModified`（数值，不带引号）
> - `{仪表板名称}` 替换为 `dashboardData.basicInfo.name`
> - `{仪表板URL}` / `{url}` 替换为 `dashboard_url`（Step 2.1 返回的标准仪表板预览页地址，已包含正确 pageId）
> - Python 代码压缩为单行式，避免 LLM "理解"但不"执行"

## 触发场景

根据仪表板分析思路以及仪表板知识库生成 5-8 个自然语言查询示例：

```markdown
## 触发场景

当用户提出以下类型的问题时使用此 Skill:
- "查询某时间段的销售额"
- "配送方式分布情况"
- "商品子类别销售排行"
- "销售额和利润的趋势"
- "查看明细数据"
```

## 前置条件

```markdown
## 前置条件

- 内置 `scripts/quickbi_openapi.py` 和 `scripts/config_loader.py` 工具函数
- 需要有效的 API 凭证配置（四层配置加载，优先级：环境变量 > 工作目录级 > 全局 > 包内默认）
- 支持仪表板更新自动检测（通过元数据中的 `skill_generated_at` 与当前 `gmtModified` 对比）

### 配置加载优先级

**读取逻辑**：四层配置加载，优先级从高到低：`ACCESS_TOKEN` 环境变量 > 工作目录级 `$WORKSPACE_DIR/.qbi/smartq-chat/config.yaml` > 全局 `~/.qbi/config.yaml` > 包内 `config.yaml`

**写入逻辑**：始终写入工作目录级配置；全局配置写入受 `save_global_property` 开关控制

### 首次使用配置指南

**配置检查流程**（必须按此逻辑执行）：

1. **调用 `load_config()` 加载配置**：
   \`\`\`python
   import sys, os
   sys.path.insert(0, os.path.join('<技能包目录>', 'scripts'))
   from config_loader import load_config
   config = load_config()  # 四层配置加载
   \`\`\`
   > **注意**：`<技能包目录>` 在生成时替换为实际路径

2. **检查配置是否完整**：
   - 检查 `config.get("api_key")`、`config.get("api_secret")`、`config.get("user_token")` 三个字段
   - **配置完整**（三个字段均非空）：**不输出任何提示**，直接继续后续流程
   - **配置不完整**（任一字段为空）：执行步骤 3 的引导流程

3. **仅当配置不完整时**，执行以下引导步骤：
   1. 使用 read_file 工具读取文件：`./example/copy_skill_config.png`，记录读取到的文件路径
      > 注：该图片由仪表板技能生成器从父 skill 的 `common/copy_skill_config.png` 复制而来
   2. 以 `![一键复制 skill 配置]({读取到的路径})` 的 Markdown 图片语法将图片展示给用户（路径以实际读取路径为准，不要写死）
   3. 在图片下方输出引导语：「请登录 Quick BI 控制台，点击头像处的「**一键复制 skill 配置**」（如上图所示），然后将复制的配置粘贴给我。」
   4. 等待用户粘贴配置，解析后写入工作目录级配置；若 `save_global_property` 为 `true`，同时写入全局配置 `~/.qbi/config.yaml`
```

## 仪表板知识库

> **【重要】图表组件必须完整列出**
> 
> 生成的 Skill 必须包含仪表板中**所有**图表组件（除容器类组件外），不能只列出"主要"图表。
> 每个图表必须包含：数据集ID（sourceId）、字段配置、分析主题、分析层级。
> 这是用户问题路由到正确数据集的关键依据。

```markdown
## 仪表板知识库

### 基本信息

| 属性 | 值 |
|------|---|
| 名称 | {title} |
| URL | `{url}` |
| pageId | `{pageId}` |

> ⚠️ 上方 pageId 和 URL 均取自 Step 2.1 脚本返回值（`result["pageId"]` / `result["dashboardUrl"]`），非用户输入的原始 URL 参数。

### 业务背景与统计口径

> **【重要】此部分信息在问数查询时必须参考，特别是统计口径和过滤条件**

{综合仪表板标题、图表标题、字段名称、富文本生成的业务背景说明，包含：}

- **业务主题**：{从仪表板标题推断的业务领域，如"销售管理分析"、"财务报表"等}
- **核心指标**：{从图表标题和度量字段提取的关键指标，如"销售额、利润、订单数"等}
- **分析维度**：{从维度字段提取的关键维度，如"时间、区域、产品线"等}
- **统计口径**：{从富文本识别的指标定义，如无则写"无特殊说明"}
- **过滤条件**：{从富文本识别的数据过滤规则，如"不含退货"、"仅已完成订单"，如无则写"无"}

### 数据集清单

> 汇总所有图表关联的数据集ID，这是 `query_openapi` 查询的核心入参

| 数据集ID | 关联图表数 | 主要用途 |
|----------|-----------|----------|
| `{sourceId1}` | {N}个 | {销售分析/库存管理等} |
| `{sourceId2}` | {N}个 | {客户分析等} |

### 查询控件 ({N} 个)

> **查询控件影响图表数据的筛选范围，理解控件配置有助于正确解读数据**

| 控件名 | 类型 | 时间粒度 | 默认值 | 需手动触发 | 关联图表 |
|--------|------|---------|--------|-----------|---------|
| {labelName} | {componentType} | {timeGranularity或-} | {defaultValue或无} | {needManualQuery: 是/否} | {关联图表列表} |

**查询控件字段说明**：
- **控件名**：`fieldConfigs[].labelName`
- **类型**：`componentType`（datetime=时间选择器, enumSelect=枚举选择器等）
- **时间粒度**：`config.timeGranularity`（day/week/month/quarter/year），影响时间筛选精度
- **默认值**：`defaultValue`，图表首次加载时的预设筛选值
- **需手动触发**：`needManualQuery`，若为"是"则用户需点击"查询"按钮才生效
- **关联图表**：该筛选器影响哪些图表的数据

### 图表组件（完整列表）

> **必须列出所有图表组件**，每个图表的数据集ID是问数查询的关键

| 图表名 | 组件类型 | 数据集ID | 维度字段 | 度量字段 | 过滤条件 | 下钻字段 | 关联筛选器 | 分析主题 | 分析层级 | 位置 | 所属Tab |
|--------|---------|---------|---------|---------|---------|---------|---------|---------|---------|------|--------|
| {图表名} | {customComponentId} | `{sourceId}` | {维度列表} | {度量(聚合)} | {filters列表或无} | {drillFields或无} | {关联的查询控件或无} | {分析主题} | L1/2/3/4 | ({x},{y}) | {Tab或-} |

**图表组件字段说明**：
- **图表名**：从 `componentName` 或 `attribute.caption` 提取
- **组件类型**：`customComponentId` 的值（如 indicator-card, ranking-list, pie 等）
- **数据集ID**：`queryInput.sourceId`，**必填项**，是 `query_openapi` 查询的核心参数（cube_id）
- **维度字段**：从 `queryInput.area` 中 `itemType` 为 dimension/datetime/geographic 的字段
- **度量字段**：从 `queryInput.area` 中 `itemType` 为 measure 的字段，标注聚合类型
- **过滤条件**：从 `queryInput.area` 中 `id` 为 `filters` 的字段，以及 `defaultFilters` 预设过滤（理解图表数据范围的关键）
- **下钻字段**：从 `queryInput.area` 中 `id` 为 `drill` 的 `columnList` 提取，支持用户下钻分析
- **关联筛选器**：从 `relatedQueryControls` 提取，标注该图表受哪些查询控件影响
- **分析主题**：根据图表类型和字段推断的业务分析场景（如"销售趋势分析"、"区域分布对比"等）
- **分析层级**：L1(整体监控)/L2(趋势分析)/L3(维度分解)/L4(明细追踪)
- **位置**：从 `componentContent.tileLayout` 提取的栅格 x/y 坐标
- **所属Tab**：如果图表属于某个Tab页，标注Tab名称

### 意图路由矩阵

> 用户问题 → 目标图表 → 数据集ID 的映射关系

| 用户意图关键词 | 目标图表 | 组件ID | 数据集ID | 分析主题 | 分析层级 | 路由说明 |
|---------------|---------|--------|---------|---------|---------|----------|
| {关键词1}, {关键词2} | {图表名} | `{componentId}` | `{sourceId}` | {分析主题} | L1/L2/L3/L4 | {为什么匹配到这个图表} |

**路由规则**：
1. 优先匹配度量字段名称（如"销售额"→ 包含销售额度量的图表）
2. 其次匹配维度字段名称（如"按区域"→ 包含区域维度的图表）
3. 再次匹配图表类型特征（如"排行"→ ranking-list 类型图表）
4. 最后匹配分析主题（如"趋势"→ 分析主题包含趋势的图表）
5. 无法匹配时，使用所有数据集ID让 `query_openapi` 自行判断
```

## 仪表板分析思路

> **【核心】此部分内容必须完全基于 `dashboardData`（Step 2.2 获取的数据）生成**
> - 所有指标名称必须来自图表的 `measures` 字段
> - 所有维度名称必须来自图表的 `dimensions` 字段
> - 分析框架匹配必须基于真实指标组合
> - 不可臆造不存在的指标或维度

```markdown
## 仪表板分析思路

### 一、报表主题

- **报表名称**：{从 basicInfo.name 获取}
- **核心主题**：{基于图表标题和指标推断的业务主题}
- **分析目标**：{解决什么业务问题}
- **目标用户**：{推断的使用者角色，如"运营人员"、"财务分析师"等}

### 二、分析框架匹配

> **基于仪表板真实指标组合自动匹配最适合的分析框架**

常用框架：杜邦分析、AARRR 海盗模型、RFM 客户分析、漏斗分析、目标达成分析、同环比分析等。无法匹配时使用 **L1-L4 金字塔**（概览→趋势→分解→明细）。

#### 本仪表板匹配结果

- **匹配的分析框架**：{根据真实指标匹配的框架名称}
- **匹配置信度**：{high/medium/default}
- **匹配依据**：基于以下真实字段匹配 - {列出匹配到的关键字段}

### 三、层级结构

> **根据仪表板复杂度选择合适的形式**

**形式 A：简单仪表板（1-2 个组件）**
- 直接列出组件即可，不强行分层
- 示例：`组件1: 销售额指标卡, 组件2: 销售趋势图`

**形式 B：标准层级仪表板（3-8 个组件，有明确层级）**
- 使用 L1-L4 金字塔描述：
```
L1 整体监控：{indicator-card/gauge 类型图表}
    └─ 核心指标：{度量字段}
    
L2 趋势分析：{line/area/indicator-trend 类型图表}
    └─ 时间维度 + 关键指标
    
L3 维度分解：{bar/pie/ranking-list 类型图表}
    └─ 分析维度 + 分析指标
    
L4 明细追踪：{common-table 类型图表}
    └─ 明细字段列表
```

**形式 C：多主题仪表板（Tab 页签或独立板块）**
- 按主题/Tab 分组描述：
```
主题1: {Tab名称或板块名}
    └─ 组件列表及层级
    
主题2: {Tab名称或板块名}
    └─ 组件列表及层级
```

### 四、核心指标体系

> **从 `dashboardData.chartComponents[].measures` 提取，不可臆造**

| 指标类型 | 指标名称 | 聚合方式 | 来源图表 | 分析层级 | 分析价值 |
|---------|---------|---------|---------|---------|---------|
| 结果指标 | {measure.caption} | {aggregateType} | {图表名} | L1 | {推断的分析价值，如"核心业绩指标"} |
| 过程指标 | {measure.caption} | {aggregateType} | {图表名} | L2/L3 | {推断的分析价值，如"过程监控指标"} |
| 效率指标 | {measure.caption} | {aggregateType} | {图表名} | L1/L2 | {推断的分析价值，如"效率评估指标"} |

**指标分类说明**：
- **结果指标**：反映最终业务成果，如销售额、利润、订单数等
- **过程指标**：反映业务过程状态，如转化率、完成率、增长率等
- **效率指标**：反映资源利用效率，如人均产出、周转率、客单价等

### 五、维度体系

> **从 `dashboardData.chartComponents[].dimensions` 提取，不可臆造**

| 维度层级 | 维度名称 | 维度类型 | 来源图表 | 分析用途 |
|---------|---------|---------|---------|---------|
| 时间维度 | {dimension.caption} | datetime | {图表名} | 趋势分析、同环比对比 |
| 地理维度 | {dimension.caption} | geographic | {图表名} | 区域分布、地域下钻 |
| 分类维度 | {dimension.caption} | dimension | {图表名} | 结构分析、归因定位 |

**维度层级说明**：
- **时间维度**：用于趋势分析和周期对比
- **地理维度**：用于区域分布和地域下钻
- **分类维度**：用于结构分析和问题归因

### 六、业务逻辑

> **基于真实指标字段名称推断可能的计算关系**

**指标关系**（基于字段名称推断）：
- {推断的计算公式1，如：利润 = 销售额 - 成本}
- {推断的计算公式2，如：毛利率 = 利润 / 销售额 × 100%}
- {推断的计算公式3，如：客单价 = 销售额 / 订单数}

**业务逻辑链**：
{指标间的因果关系链，如：}
- 销售额 ← 客户数 × 客单价
- 利润 ← 销售额 - 成本
- 毛利率 ← 利润 / 销售额

**分析路径**：
```
发现问题(L1 指标异常) 
    → 趋势定位(L2 确定异常时间点) 
    → 维度归因(L3 定位问题维度) 
    → 明细追溯(L4 查看具体记录)
```

### 七、联动与下钻路径

| 源层级 | 源图表 | 联动动作 | 目标层级 | 目标图表 | 分析用途 |
|-------|-------|---------|---------|---------|---------|
| L1 | {指标卡图表名} | 点击/筛选 | L2 | {趋势图表名} | 从总览下钻到趋势，确定异常时间点 |
| L2 | {趋势图表名} | 点击时间点 | L3 | {分布图表名} | 从趋势下钻到维度，定位问题分类 |
| L3 | {分布图表名} | 点击维度值 | L4 | {明细表名} | 从维度下钻到明细，追溯具体记录 |

### 八、适用场景与查询路径

| 业务问题类型 | 典型问题示例 | 分析路径 | 涉及层级 | 涉及指标/维度 |
|-------------|-------------|---------|---------|--------------|
| 现状查询 | "当前{指标名}是多少" | L1 直接查询 | L1 | {指标名} |
| 趋势分析 | "{指标名}趋势如何" | L1→L2 | L1, L2 | {时间维度} + {指标名} |
| 对比分析 | "各{维度}的{指标名}对比" | L1→L3 | L1, L3 | {分类维度} + {指标名} |
| 问题诊断 | "为什么{指标名}下降" | L1→L2→L3 | L1, L2, L3 | {相关指标链} |
| 归因定位 | "哪个{维度}有问题" | L3 深入 | L3 | {分类维度} + {多个指标} |
| 明细追溯 | "查看{主题}明细" | L3→L4 | L3, L4 | {维度} + {明细字段} |
| 排行查询 | "{维度}排行TOP N" | L3 排序 | L3 | {分类维度} + {指标名} |
```

## 工作流程

```markdown
## 工作流程

### Step 1: 理解用户问题

参照「仪表板知识库」和「分析思路」以及 「分析路径」理解用户问题，**必须输出理解结果**：

1. **解析用户问题**：提取关键词（指标、维度、时间范围、筛选条件）

2. **参照知识库理解**：
   - 对照「图表组件」表格，识别涉及的图表和数据集
   - 对照「分析思路」，匹配适用的分析场景
   - 对照「业务逻辑」，理解指标间的关联关系

3. **【必须输出】理解结果**：
```markdown
---
## 问题理解

- **用户意图**：{用户想要了解什么}
- **匹配分析场景**：{从分析思路中匹配的场景，如"趋势分析"、"对比分析"；多 Tab 仪表板需全面考虑各 Tab 的分析场景}
- **涉及指标**：{相关的度量字段}
- **涉及维度**：{相关的维度字段}
---
```

### Step 2: 判断问题分类并拆解

根据 Step 1 的理解结果，判断问题类型并输出分析计划：

#### 分类规则

| 类型 | 判断条件 | 处理方式 |
|------|----------|----------|
| **简单问题** | 用户问题与某图表名高度吻合 | 无需拆解，直接查询该图表对应的维度+指标 |
| **复合问题** | 需要多维度分析或归因 | 拆解为 2-3 个子问题 |

#### 子问题拆解规范（仅复合问题需要）

1. **三要素**：每个子问题必须明确 `分析维度` + `待查指标` + `目的`
2. **单表原则**：每个子问题应能让 SmartQ 直接返回一张表，避免多维度交叉
3. **查询顺序**：按 L1→L2→L3 逻辑排列（先总体 → 再维度分解 → 最后细分定位）
4. **动态补充**：根据查询结果动态决定是否需要追加问题（非固定数量）
5. **比率处理**：若问题涉及比率（如增长率、转化率），可先尝试直接查询；如SmartQ无法识别或结果异常，再拆解为基础指标（分子/分母）自行计算

#### 输出格式（此步骤输出给用户查看）

```
## 分析计划

**用户问题**：{原始问题}
**分析层级**：{L1/L2/L3/L4} - {简要说明，如"基于L1整体销售额，下钻到L2门店维度"}

| # | 子问题 | 维度 | 指标 | 目的 |
|---|--------|------|------|------|
| 1 | {问题描述} | {维度} | {指标} | {目的} |
| 2 | {问题描述} | {维度} | {指标} | {目的} |
```

### Step 3: 构建查询

根据问题分类构建查询：

**简单问题**：
```markdown
- **查询问题**：{用户原始问题或转换后的查询语句}
- **数据集ID**：{从匹配图表获取的 sourceId}
```

**复杂问题**（拆解为 2-4 个子问题）：
```markdown
---
## 子问题清单

| 序号 | 子问题 | 数据集ID | 分析目的 |
|------|--------|---------|----------|
| 1 | {子问题1} | `{sourceId}` | {这个子问题要解答什么} |
| 2 | {子问题2} | `{sourceId}` | {这个子问题要解答什么} |
| 3 | {子问题3} | `{sourceId}` | {这个子问题要解答什么} |
---
```

**兜底处理**：如果无法匹配到具体图表，收集所有图表组件的 `数据集ID`，去重后作为多数据集入参。

### Step 4: 调用 SmartQ

使用 Step 3 构建的查询，调用内置的 `scripts/quickbi_openapi.py` 中的 `query_openapi` 函数：

```python
import sys, os
sys.path.insert(0, os.path.join('<技能包目录>', 'scripts'))
from quickbi_openapi import query_openapi
from config_loader import load_config

# 加载配置（优先级：环境变量 > 工作目录级 > 全局 > 包内默认）
config = load_config()

# 简单问题：单次调用
result = query_openapi(
    endpoint=config["server_domain"],
    [REDACTED_SECRET]"api_key"],
    [REDACTED_SECRET]"api_secret"],
    question=query_question,
    user_id=config["user_token"],
    cube_id=dataset_id  # 单个数据集ID 或 逗号分隔的多个ID
)

# 复杂问题：循环调用每个子问题
results = []
for sub_question in sub_questions:
    result = query_openapi(
        endpoint=config["server_domain"],
        [REDACTED_SECRET]"api_key"],
        [REDACTED_SECRET]"api_secret"],
        question=sub_question["question"],
        user_id=config["user_token"],
        cube_id=sub_question["cube_id"]
    )
    results.append({"sub_question": sub_question, "result": result})
    # 根据结果动态决定是否补充查询
    # 如发现异常值，可动态添加第3个问题
```

**多次调用规则**：
- 每个子问题独立调用一次 `query_openapi`，最多 **3 次**（极简原则）
- 按 Step 1 拆解的顺序逐条执行
- **动态补充**：前一次结果如发现异常（如某维度值过高/过低），可补充下一个问题深入分析

**重要约束**：
- **数据正常返回时不尝试其他方式**：当 API 正常返回数据（包括空数据、全0数据等），直接展示
- **仅异常时降级到浏览器**：只有当 API 返回异常（接口抛错、网络不通、权限错误等）时，才尝试打开仪表板
- **不添加个人判断**：不对数据的合理性进行判断，直接展示 API 返回的原始结果

### Step 5: 汇总结果展示

根据问题分类展示结果：

**简单问题**：
```markdown
## 查询结果

**智能总结**: {ConclusionText，如有}

| 列1 | 列2 | ... |
|-----|-----|-----|
| 数据... |

*共 N 条数据*
```

**复杂问题**：
```markdown
## 分析结果

### 子问题 1: {子问题描述}
{该子问题的查询结果表格}

### 子问题 2: {子问题描述}
{该子问题的查询结果表格}

...

### 综合分析
{基于各子问题结果的综合分析，回答用户原始问题}

**来源**：[仪表板名称]({URL})
```

**来源链接说明**：
- URL 从「仪表板知识库 - 基本信息 - URL」字段获取
- 如果问题匹配到了具体的图表组件，则在 URL 末尾追加 `&componentId={componentId}&highlight=true`

## 错误处理

| 场景 | 检测方式 | 处理策略 |
|------|---------|----------|
| 问题理解失败 | 无法提取有效关键词 | 询问用户具体想查询什么数据 |
| 意图匹配失败 | 无法匹配到图表组件 | 使用所有数据集ID进行兜底查询 |
| API 调用失败 | 返回错误码 | 根据错误码提示用户（权限、实例过期等）|
| 部分子问题失败 | 部分 API 调用失败 | 展示成功的结果，标注失败的子问题 |

**注意**：API 成功返回的数据（包括空数据、全0数据等）直接展示给用户，不进行二次查询或修正。
```


path: references/document/document_classification.md
content: # 文档分类体系 — 详细字段定义（V2.0）

本文件定义了 **10 大分类组、37 个文档子类型**及其标准提取字段。Agent 提取文本字段并进行归并时，必须参照此文件。

**字段命名规则**：英文 `snake_case`，Excel 表头使用中文名（括号内为英文字段名）。每个子类型的字段列表中，`filename`（源文件名）为隐含首列，无需在此定义。

---

## A. 财务与税务类 (finance-tax)

### A1. 增值税发票 (vat-invoice)
*(Sheet: `增值税发票`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| invoice_type | 发票类型 | 专用/普通/电子/红字 |
| invoice_code | 发票代码 | 10-12位 |
| invoice_number | 发票号码 | 8位 |
| date | 开票日期 | YYYY-MM-DD |
| buyer_name | 购买方名称 | |
| buyer_tax_id | 购买方税号 | |
| seller_name | 销售方名称 | |
| seller_tax_id | 销售方税号 | |
| items | 货物/服务名称 | 多项分号分隔 |
| amount_before_tax | 金额（不含税） | 数值，2位小数 |
| tax_rate | 税率 | 如 13%、9%、6% |
| tax_amount | 税额 | 数值 |
| total_amount | 价税合计 | 数值 |
| remarks | 备注 | |

### A2. 银行回单 (bank-receipt)
*(Sheet: `银行回单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| bank_name | 银行名称 | |
| serial_number | 流水号/凭证号 | |
| transaction_date | 交易日期 | YYYY-MM-DD |
| payer_name | 付款人名称 | |
| payer_account | 付款人账号 | |
| payee_name | 收款人名称 | |
| payee_account | 收款人账号 | |
| amount | 交易金额 | 数值 |
| currency | 币种 | 默认 CNY |
| transaction_type | 交易类型 | 转账/汇款/代付等 |
| purpose | 用途/摘要 | |

### A3. 银行对账单 (bank-statement)
*(Sheet: `银行对账单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| bank_name | 银行名称 | |
| account_number | 账号 | |
| account_name | 户名 | |
| statement_period | 对账周期 | 如 2026-01 至 2026-03 |
| opening_balance | 期初余额 | 数值 |
| closing_balance | 期末余额 | 数值 |
| total_debit | 借方合计 | 数值 |
| total_credit | 贷方合计 | 数值 |
| transaction_count | 交易笔数 | 整数 |

### A4. 费用报销单 (expense-claim)
*(Sheet: `费用报销单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| applicant | 报销人 | |
| department | 部门 | |
| claim_date | 报销日期 | YYYY-MM-DD |
| expense_items | 费用项目 | 分号分隔 |
| total_amount | 报销金额 | 数值 |
| payment_method | 支付方式 | 现金/转账等 |
| approver | 审批人 | |
| remarks | 备注 | |

### A5. 合同/协议 (contract)
*(Sheet: `合同协议`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| contract_number | 合同编号 | |
| contract_title | 合同名称 | |
| party_a | 甲方 | |
| party_b | 乙方 | |
| contract_type | 合同类型 | 采购/服务/租赁/其他 |
| effective_date | 生效日期 | YYYY-MM-DD |
| expiry_date | 到期日期 | YYYY-MM-DD |
| contract_value | 合同金额 | 数值 |
| currency | 币种 | 默认 CNY |
| key_terms | 关键条款 | 摘要≤150字 |
| signature_date | 签署日期 | YYYY-MM-DD |

### A6. 税务申报表 (tax-return)
*(Sheet: `税务申报表`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| taxpayer_name | 纳税人名称 | |
| tax_id | 纳税人识别号 | |
| declaration_period | 申报所属期 | YYYY-MM |
| tax_type | 税种 | 增值税/企业所得税等 |
| taxable_amount | 计税依据 | 数值 |
| tax_payable | 应纳税额 | 数值 |
| tax_paid | 已缴税额 | 数值 |
| deduction_amount | 减免税额 | 数值 |
| declaration_status | 申报状态 | 已申报/未申报/更正 |

### A7. 财务报表 (financial-statement)
*(Sheet: `财务报表`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| company_name | 企业名称 | |
| report_period | 报表期间 | 如 2025-Q3 |
| total_assets | 资产总额 | 数值 |
| total_liabilities | 负债总额 | 数值 |
| equity | 所有者权益 | 数值 |
| revenue | 营业收入 | 数值 |
| net_profit | 净利润 | 数值 |
| cash_flow_operating | 经营现金流 | 数值 |
| audit_opinion | 审计意见 | 无保留/保留/否定等 |

### A8. 收据/付款凭证 (receipt-payment)
*(Sheet: `收据付款凭证`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| receipt_number | 收据编号 | |
| date | 收款日期 | YYYY-MM-DD |
| payer | 付款方 | |
| payee | 收款方 | |
| amount | 金额 | 数值 |
| payment_method | 支付方式 | 现金/转账/支票等 |
| purpose | 款项用途 | |
| issuer_signature | 开具人签字 | 文本或“有/无” |

---

## B. 人力资源类 (hr)

### B1. 简历 (resume)
*(Sheet: `简历`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| name | 姓名 | |
| gender | 性别 | |
| birth_date | 出生日期 | YYYY-MM-DD 或年龄 |
| phone | 手机号 | |
| email | 邮箱 | |
| highest_education | 最高学历 | 本科/硕士/博士等 |
| school | 毕业院校 | 最高学历院校 |
| major | 专业 | |
| work_years | 工作年限 | |
| latest_company | 最近工作单位 | |
| latest_position | 最近职位 | |
| skills | 技能关键词 | 用分号分隔 |
| expected_salary | 期望薪资 | 如有 |

### B2. 劳动合同 (labor-contract)
*(Sheet: `劳动合同`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| employee_name | 员工姓名 | |
| id_number | 身份证号 | |
| employer_name | 用人单位 | |
| contract_type | 合同类型 | 固定期限/无固定期限/完成一定工作 |
| start_date | 合同起始日 | YYYY-MM-DD |
| end_date | 合同终止日 | YYYY-MM-DD |
| position | 岗位 | |
| work_location | 工作地点 | |
| salary | 薪酬 | 数值或描述 |
| probation_period | 试用期 | 如 3个月 |
| probation_salary | 试用期薪酬 | |
| signature_date | 签订日期 | YYYY-MM-DD |

### B3. 离职证明 (resignation-cert)
*(Sheet: `离职证明`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| employee_name | 员工姓名 | |
| id_number | 身份证号 | |
| company_name | 公司名称 | |
| position | 职位 | |
| entry_date | 入职日期 | YYYY-MM-DD |
| leave_date | 离职日期 | YYYY-MM-DD |
| leave_reason | 离职原因 | 如有 |
| issue_date | 开具日期 | YYYY-MM-DD |

### B4. 工资条 (payslip)
*(Sheet: `工资条`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| employee_name | 员工姓名 | |
| employee_id | 工号 | |
| pay_period | 工资周期 | 如 2026-03 |
| base_salary | 基本工资 | 数值 |
| allowances | 津贴/补贴 | 数值 |
| overtime_pay | 加班费 | 数值 |
| bonus | 奖金 | 数值 |
| gross_pay | 应发合计 | 数值 |
| social_insurance | 社保扣除 | 数值 |
| housing_fund | 公积金扣除 | 数值 |
| tax | 个税扣除 | 数值 |
| other_deductions | 其他扣款 | 数值 |
| net_pay | 实发工资 | 数值 |

### B5. 考勤记录 (attendance-record)
*(Sheet: `考勤记录`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| employee_id | 员工ID | |
| date | 日期 | YYYY-MM-DD |
| clock_in | 打卡时间(入) | HH:MM |
| clock_out | 打卡时间(出) | HH:MM |
| work_hours | 工时 | 数值 |
| status | 异常状态 | 正常/迟到/早退/缺卡/请假 |
| department | 部门 | |

### B6. 培训证书 (training-cert)
*(Sheet: `培训证书`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| trainee_name | 学员姓名 | |
| course_name | 课程名称 | |
| training_org | 培训机构 | |
| issue_date | 颁发日期 | YYYY-MM-DD |
| valid_until | 有效期至 | YYYY-MM-DD |
| certificate_no | 证书编号 | |
| credits_hours | 学分/课时 | 数值 |

### B7. 绩效考核表 (performance-review)
*(Sheet: `绩效考核表`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| employee_id | 员工ID | |
| review_period | 考核周期 | 如 2025-Q3 |
| kpi_scores | KPI得分 | 指标:得分，分号分隔 |
| overall_rating | 综合评级 | S/A/B/C/D |
| manager_comments | 主管评语 | 摘要 |
| next_goals | 下期目标 | 摘要 |
| review_date | 考核日期 | YYYY-MM-DD |

---

## C. 供应链与采购类 (supply-chain)

### C1. 采购订单 (purchase-order)
*(Sheet: `采购订单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| po_number | 订单编号 | |
| date | 下单日期 | YYYY-MM-DD |
| supplier | 供应商 | |
| buyer | 采购方 | |
| items | 物料/商品 | 多项用分号分隔 |
| quantities | 数量 | 对应 items |
| unit_prices | 单价 | 对应 items |
| total_amount | 总金额 | 数值 |
| currency | 币种 | 默认 CNY |
| delivery_date | 交货日期 | YYYY-MM-DD |
| payment_terms | 付款条件 | |

### C2. 送货单 (delivery-note)
*(Sheet: `送货单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| delivery_number | 送货单号 | |
| date | 送货日期 | YYYY-MM-DD |
| supplier | 供应商/发货方 | |
| receiver | 收货方 | |
| items | 货物名称 | 多项用分号分隔 |
| quantities | 数量 | 对应 items |
| delivery_address | 送货地址 | |
| receiver_name | 签收人 | |
| related_po | 关联采购订单号 | 如有 |

### C3. 入库单 (warehouse-receipt)
*(Sheet: `入库单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| receipt_number | 入库单号 | |
| date | 入库日期 | YYYY-MM-DD |
| supplier | 供应商 | |
| warehouse | 入库仓库 | |
| items | 物料名称 | 多项用分号分隔 |
| quantities | 数量 | 对应 items |
| inspector | 验收人 | |
| related_po | 关联采购订单号 | 如有 |
| remarks | 备注 | |

### C4. 质检报告 (quality-report)
*(Sheet: `质检报告`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| report_number | 报告编号 | |
| date | 检测日期 | YYYY-MM-DD |
| product_name | 产品名称 | |
| batch_number | 批号 | |
| specification | 规格型号 | |
| test_items | 检测项目 | 多项用分号分隔 |
| test_results | 检测结果 | 对应 test_items |
| conclusion | 结论 | 合格/不合格 |
| inspector | 检验员 | |
| issuing_org | 出具机构 | |

### C5. 供应商评估表 (supplier-evaluation)
*(Sheet: `供应商评估表`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| supplier_name | 供应商名称 | |
| evaluation_period | 评估周期 | 如 2025-Q3 |
| quality_score | 质量评分 | 1-10分 |
| delivery_score | 交期评分 | 1-10分 |
| cost_score | 价格评分 | 1-10分 |
| service_score | 服务评分 | 1-10分 |
| overall_rating | 综合评级 | A/B/C/D |
| risk_level | 风险等级 | 高/中/低 |
| evaluator | 评估人 | |

### C6. 库存盘点表 (inventory-count)
*(Sheet: `库存盘点表`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| warehouse | 仓库名称 | |
| location_code | 库位编码 | |
| item_code | 物料编码 | |
| book_qty | 账面数量 | 数值 |
| actual_qty | 实盘数量 | 数值 |
| variance_qty | 差异数量 | 数值 |
| variance_reason | 差异原因 | 摘要 |
| count_date | 盘点日期 | YYYY-MM-DD |
| counter | 盘点人 | |

---

## D. 行政与法务类 (admin-legal)

### D1. 营业执照 (business-license)
*(Sheet: `营业执照`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| company_name | 企业名称 | |
| unified_credit_code | 统一社会信用代码 | 18位 |
| legal_representative | 法定代表人 | |
| company_type | 企业类型 | 有限责任/股份有限等 |
| registered_capital | 注册资本 | |
| establishment_date | 成立日期 | YYYY-MM-DD |
| business_scope | 经营范围 | 摘要，200字以内 |
| address | 住所 | |
| valid_period | 营业期限 | |

### D2. 身份证 (id-card)
*(Sheet: `身份证`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| name | 姓名 | |
| gender | 性别 | 男/女 |
| ethnicity | 民族 | |
| birth_date | 出生日期 | YYYY-MM-DD |
| address | 住址 | |
| id_number | 身份证号码 | 18位 |
| issuing_authority | 签发机关 | |
| valid_period | 有效期限 | 起止日期 |

### D3. 护照 (passport)
*(Sheet: `护照`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| name_cn | 中文姓名 | |
| name_en | 英文姓名 | |
| nationality | 国籍 | |
| gender | 性别 | |
| birth_date | 出生日期 | YYYY-MM-DD |
| birth_place | 出生地 | |
| passport_number | 护照号码 | |
| issue_date | 签发日期 | YYYY-MM-DD |
| expiry_date | 有效期至 | YYYY-MM-DD |
| issuing_authority | 签发机关 | |

### D4. 保密协议 (nda)
*(Sheet: `保密协议`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| party_a | 甲方 | |
| party_b | 乙方 | |
| sign_date | 签署日期 | YYYY-MM-DD |
| confidential_period | 保密期限 | 如 3年/永久 |
| scope_summary | 保密范围摘要 | ≤150字 |
| penalty_clause | 违约赔偿条款 | 摘要 |
| governing_law | 管辖法律 | |

### D5. 资质证书 (qualification-license)
*(Sheet: `资质证书`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| cert_number | 证书编号 | |
| holder_name | 持证主体 | |
| issuing_authority | 发证机关 | |
| issue_date | 发证日期 | YYYY-MM-DD |
| expiry_date | 有效期至 | YYYY-MM-DD |
| license_scope | 许可范围 | 摘要 |
| status | 状态 | 有效/吊销/过期 |

### D6. 公文/通知 (official-notice)
*(Sheet: `公文通知`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| doc_number | 文号 | |
| issuing_dept | 发文机关 | |
| title | 标题 | |
| publish_date | 发布日期 | YYYY-MM-DD |
| target_audience | 主送/抄送单位 | |
| content_summary | 正文摘要 | ≤200字 |
| urgency | 紧急程度 | 特急/加急/平件 |
| confidential_level | 密级 | 绝密/机密/秘密/公开 |

---

## E. 医疗类 (medical)

### E1. 病历 (medical-record)
*(Sheet: `病历`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| patient_name | 患者姓名 | |
| gender | 性别 | |
| age | 年龄 | |
| visit_date | 就诊日期 | YYYY-MM-DD |
| hospital | 医疗机构 | |
| department | 科室 | |
| doctor | 主治医生 | |
| chief_complaint | 主诉 | 简述，100字以内 |
| diagnosis | 诊断 | |
| treatment_plan | 治疗方案 | 摘要 |
| medications | 用药 | 多项用分号分隔 |

### E2. 处方单 (prescription)
*(Sheet: `处方单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| patient_name | 患者姓名 | |
| gender | 性别 | |
| age | 年龄 | |
| prescription_date | 开具日期 | YYYY-MM-DD |
| hospital | 医疗机构 | |
| department | 科室 | |
| doctor | 处方医生 | |
| diagnosis | 临床诊断 | |
| medications | 药品名称 | 多项用分号分隔 |
| dosage | 剂量 | 对应 medications |
| usage | 用法 | 如 口服 tid |
| duration | 疗程 | 如 7天 |

### E3. 检验/检查报告 (lab-report)
*(Sheet: `检验检查报告`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| report_no | 报告编号 | |
| patient_id | 患者ID | |
| test_date | 检查日期 | YYYY-MM-DD |
| test_items | 检测项目 | 分号分隔 |
| results | 结果值 | 分号分隔，对应项目 |
| reference_ranges | 参考范围 | 分号分隔 |
| abnormal_flags | 异常标识 | ↑/↓/正常 |
| critical_value | 危急值标记 | 是/否 |
| reviewer | 审核医师 | |

### E4. 体检报告 (health-checkup)
*(Sheet: `体检报告`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| checkup_no | 体检编号 | |
| check_date | 体检日期 | YYYY-MM-DD |
| dept_items | 科室/项目 | 分号分隔 |
| indicators | 指标值 | 分号分隔 |
| normal_abnormal | 正常/异常 | 分号分隔 |
| health_advice | 健康建议 | 摘要 |
| overall_risk | 总体风险等级 | 高/中/低 |
| doctor | 总检医师 | |

---

## F. 保险类 (insurance)

### F1. 保单 (insurance-policy)
*(Sheet: `保单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| policy_number | 保单号 | |
| insurance_company | 保险公司 | |
| insurance_type | 险种 | 寿险/车险/财产险等 |
| insured_name | 被保险人 | |
| insured_id | 被保险人证件号 | |
| policyholder | 投保人 | |
| coverage_amount | 保额 | 数值 |
| premium | 保费 | 数值 |
| payment_frequency | 缴费方式 | 年缴/月缴/趸缴 |
| effective_date | 生效日期 | YYYY-MM-DD |
| expiry_date | 到期日期 | YYYY-MM-DD |
| beneficiary | 受益人 | |

### F2. 理赔申请 (insurance-claim)
*(Sheet: `理赔申请`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| claim_number | 理赔编号 | |
| policy_number | 关联保单号 | |
| claimant | 申请人 | |
| incident_date | 出险日期 | YYYY-MM-DD |
| incident_type | 出险类型 | 意外/疾病/车祸等 |
| incident_description | 事故描述 | 摘要，150字以内 |
| claim_amount | 申请金额 | 数值 |
| hospital | 就诊医院 | 如适用 |
| supporting_docs | 附件材料 | 列出提交的证明文件 |
| claim_date | 申请日期 | YYYY-MM-DD |

### F3. 理赔结案通知 (claim-settlement)
*(Sheet: `理赔结案通知`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| claim_no | 理赔编号 | |
| policy_no | 关联保单号 | |
| settlement_date | 结案日期 | YYYY-MM-DD |
| approved_amount | 核定赔付金额 | 数值 |
| deductible | 免赔额 | 数值 |
| payment_method | 支付方式 | 银行转账/支票等 |
| closure_reason | 结案原因 | 赔付/拒赔/撤诉 |
| insurer_signature | 保险公司签章 | 文本或“有/无” |

---

## G. 物流类 (logistics)

### G1. 运单 (waybill)
*(Sheet: `运单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| waybill_number | 运单号 | |
| carrier | 承运方 | 物流公司名称 |
| shipper | 发货人 | |
| shipper_address | 发货地址 | |
| consignee | 收货人 | |
| consignee_address | 收货地址 | |
| goods_description | 货物描述 | |
| quantity | 件数 | 整数 |
| weight | 重量(kg) | 数值 |
| freight | 运费 | 数值 |
| shipment_date | 发货日期 | YYYY-MM-DD |
| delivery_date | 预计到达日期 | YYYY-MM-DD |

### G2. 提单 (bill-of-lading)
*(Sheet: `提单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| bl_number | 提单号 | |
| bl_type | 提单类型 | 正本/副本/电放 |
| shipper | 托运人 | |
| consignee | 收货人 | |
| notify_party | 通知方 | |
| vessel_name | 船名 | |
| voyage | 航次 | |
| port_of_loading | 装货港 | |
| port_of_discharge | 卸货港 | |
| container_number | 集装箱号 | 多个用分号分隔 |
| seal_number | 铅封号 | |
| goods_description | 货物描述 | |
| gross_weight | 毛重(kg) | 数值 |
| measurement | 体积(CBM) | 数值 |
| issue_date | 签发日期 | YYYY-MM-DD |
| number_of_originals | 正本份数 | 通常 3 |

### G3. 报关单 (customs-declaration)
*(Sheet: `报关单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| declaration_no | 报关单号 | |
| customs_code | 海关编码 | |
| importer | 进口商 | |
| exporter | 出口商 | |
| trade_mode | 贸易方式 | 一般贸易/加工贸易等 |
| goods_name | 货物名称 | |
| hs_code | HS编码 | |
| declared_value | 申报价值 | 数值 |
| currency | 币种 | USD/CNY等 |
| port | 口岸 | |
| declaration_date | 申报日期 | YYYY-MM-DD |

---

## H. 技术与运维类 (tech-ops) 🆕

### H1. 系统日志 (system-log)
*(Sheet: `系统日志`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| timestamp | 时间戳 | YYYY-MM-DD HH:MM:SS |
| log_level | 日志级别 | INFO/WARN/ERROR/FATAL |
| service_name | 服务/模块名 | |
| trace_id | 追踪ID | 用于链路追踪 |
| error_code | 错误码 | 如有 |
| ip_address | IP地址 | 来源或目标 |
| message_summary | 消息摘要 | ≤200字 |
| stack_trace | 堆栈关键行 | 截取关键报错 |

### H2. 漏洞扫描报告 (vulnerability-report)
*(Sheet: `漏洞扫描报告`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| report_id | 报告编号 | |
| scan_date | 扫描时间 | YYYY-MM-DD |
| target_system | 目标系统/IP | |
| vuln_name | 漏洞名称 | |
| cve_id | CVE编号 | 如有 |
| risk_level | 风险等级 | 高/中/低/信息 |
| affected_component | 影响组件 | |
| remediation | 修复建议 | 摘要 |
| status | 修复状态 | 未修复/已修复/忽略 |

### H3. 服务器监控报表 (server-monitoring)
*(Sheet: `服务器监控报表`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| host_name | 主机名/IP | |
| monitor_time | 监控时间 | YYYY-MM-DD HH:MM |
| cpu_usage | CPU使用率(%) | 数值 |
| mem_usage | 内存使用率(%) | 数值 |
| disk_io | 磁盘IO(MB/s) | 数值 |
| network_traffic | 网络流量(Mbps) | 数值 |
| alert_events | 告警事件数 | 整数 |
| sla_availability | 可用性SLA(%) | 数值 |

---

## I. 客服与销售类 (sales-service) 🆕

### I1. 客诉/工单记录 (customer-ticket)
*(Sheet: `客诉工单记录`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| ticket_id | 工单号 | |
| customer_id | 客户ID | |
| create_time | 创建时间 | YYYY-MM-DD HH:MM |
| category | 问题分类 | 产品/物流/售后/账单等 |
| priority | 紧急程度 | P1/P2/P3/P4 |
| handler | 处理人 | |
| sla_hours | 解决时长(小时) | 数值 |
| csat_score | 满意度评分 | 1-5分 |
| status | 工单状态 | 待处理/处理中/已关闭 |

### I2. 销售报价单 (sales-quotation)
*(Sheet: `销售报价单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| quote_no | 报价单号 | |
| quote_date | 报价日期 | YYYY-MM-DD |
| customer_name | 客户名称 | |
| sales_rep | 销售代表 | |
| items | 产品/服务 | 分号分隔 |
| unit_price | 单价 | 对应items |
| quantity | 数量 | 对应items |
| total_amount | 报价总额 | 数值 |
| valid_until | 报价有效期 | YYYY-MM-DD |
| terms | 商务条款 | 摘要 |

### I3. 售后退换货单 (return-exchange)
*(Sheet: `售后退换货单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| return_no | 退换单号 | |
| original_order | 原订单号 | |
| apply_date | 申请日期 | YYYY-MM-DD |
| reason | 退换原因 | 质量/错发/七天无理由等 |
| items | 退换商品 | 分号分隔 |
| refund_amount | 退款金额 | 数值 |
| logistics_no | 退回物流单号 | |
| approval_status | 审批状态 | 待审/通过/驳回 |

---

## J. 政务与合规类 (gov-compliance) 🆕

### J1. 招投标文件 (bidding-doc)
*(Sheet: `招投标文件`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| project_no | 项目编号 | |
| tenderer | 招标方 | |
| bidder | 投标方 | |
| bid_date | 开标日期 | YYYY-MM-DD |
| bid_price | 投标报价 | 数值 |
| tech_summary | 技术方案摘要 | ≤200字 |
| qualification | 资质证明 | 列出核心资质 |
| evaluation_score | 评标得分 | 数值 |
| result | 中标状态 | 中标/未中标/流标 |

### J2. 政务审批单 (gov-approval)
*(Sheet: `政务审批单`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| approval_no | 审批单号 | |
| applicant | 申请人/企业 | |
| matter | 申请事项 | |
| submit_date | 提交日期 | YYYY-MM-DD |
| process_nodes | 审批环节 | 分号分隔 |
| result | 批复结果 | 通过/驳回/补正 |
| valid_period | 有效期 | YYYY-MM-DD 至 YYYY-MM-DD |
| attachments | 附件清单 | 分号分隔 |

### J3. 合规审计报告 (compliance-audit)
*(Sheet: `合规审计报告`)*

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| audit_target | 审计对象 | |
| audit_period | 审计期间 | |
| auditor | 审计机构 | |
| high_issues | 高风险问题数 | 整数 |
| medium_issues | 中风险问题数 | 整数 |
| low_issues | 低风险问题数 | 整数 |
| rectification_status | 整改状态 | 未开始/进行中/已完成 |
| conclusion | 审计结论 | 摘要 |
| rating | 合规评级 | A/B/C/D |

---

## 未识别类 (unrecognized)

Sheet 名：`未识别`

无法匹配任何预定义子类型的文档归入此类。

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| raw_text_preview | 内容预览 | parsedText 前 300 字符 |
| possible_type | 疑似类型 | agent 的最佳猜测（如有） |
| confidence | 置信度 | 高/中/低 |
| reason | 未识别原因 | 内容模糊/类型未覆盖/OCR质量差 |

## 解析失败类 (parse-failed)

Sheet 名：`解析失败`

document-parser API 返回 status=failed 的文件。

| 字段名 | 中文表头 | 说明 |
|--------|---------|------|
| error_message | 错误信息 | API 返回的 errorMessage |
| file_type | 文件类型 | 文件扩展名 |
| file_size | 文件大小 | KB/MB |

---

## 📐 分类决策指引（V2.0 更新版）

### 核心优先级规则
1. **标题/抬头优先**：发票/回单/对账单/报销单 → A组；合同/协议 → 按内容判定归属（劳动→B2，采购→C1，其他→A5/D4）
2. **金额单据路由**：含税额/税号 → A1；含流水号/账户 → A2/A3；含报销明细 → A4；无税号仅金额 → A8
3. **证照/资质**：营业执照/身份证/护照/许可证 → D组，不混入财务
4. **医疗 vs 保险**：病历/处方/检验/体检 → E组；保单/理赔/结案 → F组
5. **技术/运维**：日志/漏洞/监控 → H组；客服/销售/退换 → I组；招投标/审批/审计 → J组

### 边界 Case 处理
6. **收据 vs 发票**：无税号/无税额 → A8；有完整税务信息 → A1
7. **红字/负数单据**：仍归原类，金额填负值，类型字段标注“红字/冲正”
8. **多页复合文档**：按首页/核心业务分类，在 `remarks` 或 `key_terms` 标注“含多类型附件”
9. **OCR 质量低/置信度不足**：强制归入 `unrecognized`，禁止强行提取
10. **空白/无文本/纯图片**：归入 `parse-failed` 或 `unrecognized`（视解析API返回状态）

### 多语言映射（新增）
| 英文/外文 | 对应分类 | 备注 |
|-----------|----------|------|
| Commercial Invoice / Tax Invoice | A1 | 国际发票结构类似 |
| Bank Statement / Advice | A2/A3 | 按明细或汇总区分 |
| Payslip / Salary Advice | B4 | |
| Purchase Order (PO) / Sales Order (SO) | C1 / I2 | SO 归报价单或订单 |
| Delivery Note / Packing List | C2 | |
| Certificate of Analysis (COA) | C4 | |
| Bill of Lading (B/L) / AWB | G2 / G1 | |
| System Log / Error Log | H1 | |
| Vulnerability Scan Report | H2 | |
| Customer Ticket / Case | I1 | |
| Bidding Document / Tender | J1 | |
| Audit Report / Compliance Review | J3 | |

### 动态扩展机制
若文档明确不属于现有 37 类但可识别性质：
1. 创建新子类型，Sheet 名 ≤31 字符
2. 至少定义 5 个核心字段（含 1 个日期/金额/状态类分析字段）
3. 提交字段定义供业务方确认后纳入路由表

---


path: references/document/module-document-parser.md
content: ---
name: quickbi-document-parser
description: >
  文档智能解析与结构化提取工具。当用户需要识别 PDF、Word、Excel、CSV、图片等文档内容，
  或提取关键字段并生成结构化 Excel 报表时使用。支持单文件、批量文件和文件夹递归处理。
---

# QuickBI 文档解析工具

**核心能力**:
1. 📄 **文档内容识别**: 解析 PDF、Word、Excel、CSV、图片等非结构化文件为可读取的文本内容
2. 📊 **字段提取与汇总**: 从文档中智能提取核心字段，自动生成带格式的多 Sheet Excel 报表

## Scope

**Does:**
- 识别 PDF、Word(.doc/.docx)、Excel(.xls/.xlsx)、CSV、图片(.png/.jpg/.jpeg) 等文档内容
- 支持单文件、多文件批量处理、文件夹递归扫描
- 优先本地提取文本，失败后自动降级到远程 OCR
- 根据预定义分类体系(10 大分类组、37 个子类型)智能提取核心字段
- 支持未知文档的动态结构化提取(5+ 字段需用户确认)
- 生成带格式的多 Sheet Excel 报表(汇总统计 + 分类数据)

**Does NOT:**
- 不支持修改原始文档内容
- 不支持在线编辑 Excel
- 不支持非文档类文件(如视频、音频、可执行文件)
- 严禁杜撰或编造任何提取数据

## Instructions

本技能提供 **2 种使用模式**,根据用户意图自动选择:

### ⚠️ 模式判定规则(重要)

**严格按以下规则判断使用哪个模式**:

| 用户意图关键词 | 使用模式 | 说明 |
|--------------|---------|------|
| 识别、读取、提取文本、转成文本、查看内容 | **模式 A** | 仅需文档内容,不需要结构化 |
| 提取字段、生成 Excel、汇总报表、结构化、分类提取 | **模式 B** | 需要字段提取和 Excel 输出 |
| 解析 + 无后续说明 | **模式 A** | 默认仅识别内容 |
| 解析 + 明确提到字段/Excel/汇总 | **模式 B** | 需要完整流程 |

**关键原则**:
- 📌 **"解析"、"识别"、"读取" 等动词默认指向模式 A**
- 📌 **只有用户明确要求"提取字段"、"生成 Excel"、"汇总报表"时才使用模式 B**
- 📌 **不确定时,优先使用模式 A,然后询问用户是否需要生成 Excel**

---

### 模式 A: 文档内容识别
**适用场景**: 用户仅需读取文档中的文本内容,无需结构化提取

**处理流程**: Step 1 (文本识别)

**示例**:
- "帮我读取这个 PDF 的内容"
- "解析这个 PDF 的内容"
- "提取这些 Word 文档的文本"
- "扫描文件夹,把所有文档转成文本"

---

### 模式 B: 字段提取与 Excel 汇总
**适用场景**: 用户需要从众多文档中，提取核心字段并生成结构化的Excel

**处理流程**: Step 1 (文本识别) → Step 2 (字段提取) → Step 3 (生成 Excel)

**示例**:
- "解析这些发票,提取关键字段并生成 Excel"
- "扫描合同文件夹,汇总所有合同信息到Excel"
- "批量处理文档,按分类提取字段并导出"

---

### 工作流程概览

```
用户上传文件/文件夹
  ↓
┌─────────────────────────────────────┐
│  Step 1: 文本识别                    │
│  本地解析优先 → 失败降级远程 OCR      │
│  输出: JSON (file + parsedText)      │
└─────────────────────────────────────┘
  ↓
[模式 A: 到此结束,返回文本内容]
  ↓
┌─────────────────────────────────────┐
│  Step 2: 字段提取                    │
│  智能分类 → 提取核心字段             │
│  输出: JSON (分类 + 字段数据)        │
└─────────────────────────────────────┘
  ↓
┌─────────────────────────────────────┐
│  Step 3: 生成 Excel 报表             │
│  多 Sheet + 格式化 + 汇总统计        │
│  输出: .xlsx 文件                    │
└─────────────────────────────────────┘
  ↓
输出总结 + Excel 交付物
```

### Step 1: 文档内容识别

**目标**: 提取文档中的原始文本内容,生成 JSON 文件

**核心能力**: 📄 支持 PDF、Word、Excel、CSV、图片等多种格式的智能识别

**执行逻辑**:

1. **优先调用本地解析** (`document_local_parse.py`)
   ```bash
   # 单文件
   python scripts/document/document_local_parse.py <文件路径> --json
   
   # 多文件
   python scripts/document/document_local_parse.py <文件1> <文件2> <文件3> --json
   
   # 文件夹（递归扫描）
   python scripts/document/document_local_parse.py <文件夹路径> --json
   ```

2. **如果本地解析失败**，尝试远程 OCR (`document_remote_ocr.py`)
   ```bash
   # 文件夹扫描
   python scripts/document/document_remote_ocr.py <文件夹路径>
   
   # 多文件
   python scripts/document/document_remote_ocr.py --files <文件1> <文件2>
   ```

3. **输出格式**:
   ```json
   [
     {
       "file": "filename.pdf",
       "parsedText": "提取的完整文本内容..."
     }
   ]
   ```

**注意事项**:
- 本地解析支持: PDF(PyMuPDF)、Word(python-docx)、Excel(openpyxl)、CSV(pandas)、图片(Tesseract OCR)
- 远程 OCR 支持: PDF、图片、Word、Excel、PPT（通过 QuickBI API）
- 单文件最大 10MB
- 默认输出到 `output/` 目录，带时间戳

### Step 2: 字段提取与智能分类

**目标**: 根据文档分类体系,从原始文本中提取核心字段

**核心能力**: 📊 智能分类 + 动态提取 + 用户确认机制

**执行逻辑**:

1. **加载分类体系**: 参考 `references/document_classification.md`
    - 10 大分类组: A.财务与税务、B.人力资源、C.供应链与采购、D.行政与法务、E.医疗、F.保险、G.物流、H.技术与运维、I.客服与销售、J.政务与合规
    - 37 个子类型: 每个子类型有明确的字段定义和中文表头。

2. **文档分类与字段提取**: 按照以下优先级策略处理

   **第一优先级: 匹配预定义分类体系**
    - 参考 `references/document_classification.md` 进行分类
    - 优先匹配标题/抬头（如"增值税发票"、"银行回单"）
    - 根据关键字段路由（如含税号→A1,含流水号→A2/A3）
    - 匹配成功后,严格按照对应子类型的字段定义提取数据

   **第二优先级: 动态结构化提取**
    - 如果无法匹配预定义的 37 个子类型,评估文档是否具备结构化提取价值
    - 判断标准: 能否从文本中识别并提取 **至少 5 个有效字段**
    - 如果可以提取 5+ 个字段:
        - 智能识别字段名称和对应值
        - **必须使用 AskUserQuestion 工具让用户确认字段定义**
        - 用户确认后,按确认的字段结构进行提取
        - 为新类型创建临时 Sheet 名(格式: `自定义_{类型名}`)

   **第三优先级: 归入未识别类**
    - 如果无法匹配预定义分类 **且** 无法结构化提取 5+ 个字段
    - 归入"未识别"类,记录内容预览和疑似类型

3. **字段提取**: 严格按照分类体系定义的字段提取
    - 字段命名: 英文 `snake_case`
    - Excel 表头: 中文名（括号内英文字段名）
    - 每个子类型的隐含首列: `filename`（源文件名）

4. **组装 JSON**:
   ```json
   {
     "scan_time": "2026-04-07 15:00:00",
     "total_files": 10,
     "extraction_data": {
       "增值税发票": {
         "headers_cn": ["源文件名", "发票类型", "发票代码", "发票号码", "开票日期", "购买方名称", "销售方名称", "价税合计"],
         "rows": [
           ["invoice_001.pdf", "专用", "033002100511", "03933249", "2023-05-14", "购买方公司", "销售方公司", "118.00"]
         ]
       },
       "未识别": {
         "headers_cn": ["源文件名", "内容预览", "疑似类型", "置信度"],
         "rows": [
           ["unknown.pdf", "这是一段文本...", "合同", "中"]
         ]
       }
     }
   }
   ```

**⚠️ 核心原则: 严禁杜撰数据**

- ✅ **允许**: 从 `parsedText` 中提取存在的字段值
- ✅ **允许**: 字段缺失时留空（空字符串）
- ❌ **禁止**: 编造不存在的字段和字段值，禁止杜撰数据
- ❌ **禁止**: 根据上下文推测或补全数据
- ❌ **禁止**: 修改原始文本内容
- ❌ **禁止**: 填充默认值（除非分类体系明确说明，如"币种默认 CNY"）

**提取示例**:

```python
# ✅ 正确: 从文本中提取
if "发票代码" in text:
    invoice_code = extract_value(text, "发票代码")  # 提取实际值
else:
    invoice_code = ""  # 留空，不编造

# ❌ 错误: 杜撰数据
invoice_code = "1234567890"  # 文本中没有，禁止编造
```

### Step 3: 生成 Excel 汇总报表

**目标**: 将提取的字段数据生成结构化、带格式的 Excel 报表

**核心能力**: 📈 多 Sheet 自动化 + 格式化 + 汇总统计

**执行命令**:
```bash
# 默认输出到 output/doc_scan_result_{timestamp}.xlsx
python scripts/document/generate_excel.py <Step2的JSON路径>

# 自定义输出路径
python scripts/document/generate_excel.py <Step2的JSON路径> /path/to/output.xlsx
```

**Excel 结构**:
- **excel名称** ：`{category名称}_{timestamp}.xlsx`
- **汇总 Sheet**（首页）: 统计各分类组的文件数量和提取字段
- **数据 Sheet**（每个子类型一个）: 带格式的表格数据
    - 蓝色表头（`#4472C4`）+ 白色粗体
    - 自动筛选 + 冻结首行
    - 自动列宽 + 单元格换行

### 最终交付

在窗口中输出:

1. **处理总结**:
   ```

   文档解析完成

   文件总数: 10
   成功识别: 9
   识别失败: 1
   
   分类统计:
   - A.财务与税务: 5 个文件（增值税发票 3, 银行回单 2）
   - B.人力资源: 2 个文件（简历 1, 劳动合同 1）
   - 未识别: 1 个文件
   
   提取字段: 45 个
   ```

2. **Excel 交付物路径**:
   ```
   ✓ Excel 已生成: /path/to/output/invoice_20260407_150000.xlsx
   ```

## Examples

### 模式 A 示例

**Example 1: 解析单个文件内容**

Input:
```
请帮我读取这个 PDF 的内容: /Users/user/document.pdf
```

Expected output:
```
[Step 1] 本地解析 document.pdf...
[PDF提取] 成功提取 2350 字符
[保存] JSON 结果已保存到: output/extract_results_1775575200.json


文档解析完成

文件总数: 1
成功识别: 1
提取文本: 2350 字符

✓ 文本内容已保存: output/extract_results_1775575200.json
```

**Example 2: 批量解析文件夹**

Input:
```
扫描并解析 /Users/user/documents/ 下的所有文档,提取文本内容
```

Expected output:
```
[Step 1] 扫描文件夹...
[扫描] 在 /Users/user/documents/ 中找到 15 个支持的文件
[并行提取] 开始处理 15 个文件 (最大并行数: 10)
...


文档解析完成

文件总数: 15
成功识别: 14
识别失败: 1
总文本量: 45,230 字符

✓ 文本内容已保存: output/extract_results_1775576400.json
```

---

### 模式 B 示例

**Example 3: 解析发票并生成 Excel 表格**

Input:
```
请解析这些发票文件,提取关键字段并生成 Excel 报表: /Users/user/invoices/
```

Expected output:
```
[Step 1] 本地解析 invoices/ 文件夹...
[扫描] 找到 10 个支持的文件
[并行提取] 开始处理 10 个文件 (最大并行数: 10)
...

[Step 2] 智能分类与字段提取...
- 增值税发票: 5 个文件 (提取 13 个字段/文件)
- 银行回单: 3 个文件 (提取 11 个字段/文件)
- 未识别: 2 个文件

[Step 3] 生成 Excel 汇总报表...
[格式化] 应用蓝色表头 + 自动筛选 + 冻结首行
[保存] ✓ Excel 结果已保存到: output/doc_scan_result_20260407_150000.xlsx

文档解析完成

文件总数: 10
文件总数: 10
成功识别: 8
未识别: 2

分类统计:
- A.财务与税务: 8 个文件 (增值税发票 5, 银行回单 3)
- 未识别: 2 个文件

提取字段: 98 个

✓ Excel 报表已生成: output/doc_scan_result_20260407_150000.xlsx
```

**Example 4: 本地解析失败,降级到远程 OCR**

Input:
```
解析这个扫描件 PDF 并提取字段: /Users/user/scanned_invoice.pdf
```

Expected output:
```
[Step 1] 本地解析 scanned_invoice.pdf...
[PDF提取] 警告: 本地提取文本较少 (12 字符),可能是扫描件,尝试 OCR...
[PDF提取] 降级到 Tesseract OCR 识别...
[OCR降级] OCR 识别质量不佳,尝试远程 OCR...

[远程 OCR] 上传 scanned_invoice.pdf...
[上传] ✓ scanned_invoice.pdf -> taskId: abc123
[轮询] ✓ 任务解析成功 (850 字符)

[Step 2] 智能分类: 增值税发票 (vat-invoice)
提取字段: 发票代码、发票号码、开票日期、购买方名称... (从 OCR 文本中提取)

[Step 3] 生成 Excel 汇总报表...
[保存] ✓ Excel 结果已保存到: output/doc_scan_result_20260407_160000.xlsx

文档解析完成

文件总数: 1
成功识别: 1 (远程 OCR)
文件总数: 1
成功识别: 1 (远程 OCR)

分类统计:
- A.财务与税务: 1 个文件 (增值税发票 1)

提取字段: 13 个

✓ Excel 报表已生成: output/doc_scan_result_20260407_160000.xlsx
```

**Example 5: 未知文档动态提取(需用户确认)**

Input:
```
解析这个自定义文档并提取字段: /Users/user/custom_report.pdf
```

Expected output:
```
[Step 1] 本地解析 custom_report.pdf...
[PDF提取] 成功提取 1580 字符

[Step 2] 智能分类...
⚠️ 无法匹配预定义的 37 个标准分类
🔍 评估文档结构化提取价值...
✓ 识别到 8 个潜在字段: 报告编号、检测日期、样品名称、检测项目、结果值、检测员、审核人、检测机构

[AskUserQuestion] 检测到未知文档类型,确认识别字段:
┌─────────────────────────────────────┐
│ 文档类型: 检测报告 (自定义)          │
│ 识别字段:                            │
│ 1. 报告编号 (report_no)             │
│ 2. 检测日期 (test_date)             │
│ 3. 样品名称 (sample_name)           │
│ 4. 检测项目 (test_items)            │
│ 5. 结果值 (results)                 │
│ 6. 检测员 (inspector)               │
│ 7. 审核人 (reviewer)                │
│ 8. 检测机构 (testing_org)           │
│                                     │
│ 是否确认按此结构提取?                │
└─────────────────────────────────────┘
用户确认: ✓ 是

[Step 2] 按确认结构提取字段...
[提取] 成功提取 8 个字段

[Step 3] 生成 Excel 汇总报表...
[创建 Sheet] 自定义_检测报告
[保存] ✓ Excel 结果已保存到: output/doc_scan_result_20260407_170000.xlsx

============================================================
文档解析完成
============================================================
文件总数: 1
成功识别: 1 (自定义类型)

分类统计:
- 自定义_检测报告: 1 个文件

提取字段: 8 个
============================================================
✓ Excel 报表已生成: output/doc_scan_result_20260407_170000.xlsx
```

## Additional Resources

- **分类体系详细定义**: [document_classification.md](./document_classification.md)

## 脚本接口参考

### 1. 本地解析脚本 (`document_local_parse.py`)

**功能**: 纯本地文本提取,支持 PDF/Word/Excel/CSV/图片,不依赖外部 API

**支持格式**:
- PDF(.pdf)、Word(.doc/.docx)、Excel(.xls/.xlsx)、CSV(.csv)
- 图片(.png/.jpg/.jpeg/.bmp/.tiff/.webp) - 使用 Tesseract OCR

**命令行用法**:
```bash
# 单文件
python scripts/document/document_local_parse.py <文件路径> --json

# 多文件
python scripts/document/document_local_parse.py <文件1> <文件2> <文件3> --json

# 文件夹递归扫描
python scripts/document/document_local_parse.py <文件夹路径> --json

# 自定义输出目录
python scripts/document/document_local_parse.py <路径> --json --output-dir /custom/output/

# 禁用 OCR 降级
python scripts/document/document_local_parse.py <文件路径> --json --no-ocr
```

**核心参数**:
| 参数 | 说明 | 默认值 |
|------|------|-------|
| `--json` | 保存 JSON 结果 | False |
| `--output-dir` | JSON 输出目录 | `output/` |
| `--no-ocr` | 禁用 OCR 降级 | False |

**输出格式**:
```json
[
  {"file": "filename.pdf", "parsedText": "提取的文本内容..."}
]
```

**系统依赖**:
```bash
# macOS
brew install tesseract tesseract-lang

# Ubuntu/Debian
sudo apt-get install tesseract-ocr tesseract-ocr-chi-sim tesseract-ocr-eng
```

---

### 2. 远程 OCR 脚本 (`document_remote_ocr.py`)

**功能**: 基于 QuickBI API 的远程 OCR 识别,支持批量并发处理

**支持格式**:
- PDF、图片(.png/.jpg/.jpeg/.webp/.bmp/.gif/.jp2)
- Word(.doc/.docx)、PPT(.ppt/.pptx)、Excel(.xls/.xlsx/.csv)
- **文件大小限制**: 单文件 ≤ 10MB

**命令行用法**:
```bash
# 文件夹扫描(递归)
python scripts/document/document_remote_ocr.py <文件夹路径>

# 多文件
python scripts/document/document_remote_ocr.py --files <文件1> <文件2> <文件3>

# 自定义输出路径
python scripts/document/document_remote_ocr.py <路径> --output /custom/result.json

# JSON 模式(仅输出JSON,无日志)
python scripts/document/document_remote_ocr.py <路径> --json

# 调整并发数
python scripts/document/document_remote_ocr.py <路径> --upload-workers 5 --poll-workers 10
```

**核心参数**:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `directory` | 可选 | - | 目录路径(递归扫描) |
| `--files` | 可选 | - | 文件列表(与 directory 二选一) |
| `--upload-workers` | int | 5 | 上传并发数(最大10) |
| `--poll-workers` | int | 10 | 轮询并发数(最大10) |
| `--output` | str | - | 输出 JSON 路径 |
| `--json` | flag | false | 仅输出JSON(无日志) |

**输出格式**:
```json
[
  {"file": "filename.pdf", "parsedText": "识别文本..."},
  {"file": "error.pdf", "parsedText": null, "error": "错误信息"}
]
```

**配置说明**：请参见主文件的「配置」章节。

---

### 3. Excel 生成脚本 (`generate_excel.py`)

**功能**: 将分类提取的 JSON 数据转换为多 Sheet Excel 报表

**命令行用法**:
```bash
# 默认输出到 output/doc_scan_result_{timestamp}.xlsx
python scripts/document/generate_excel.py <JSON路径>

# 自定义输出路径
python scripts/document/generate_excel.py <JSON路径> /path/to/output.xlsx
```

**输入 JSON 格式**:
```json
{
  "scan_time": "2026-04-07 15:00:00",
  "total_files": 10,
  "extraction_data": {
    "增值税发票": {
      "headers_cn": ["源文件名", "发票类型", "发票代码", "..."],
      "rows": [["file.pdf", "专用", "033002100511", "..."]]
    },
    "未识别": {
      "headers_cn": ["源文件名", "内容预览", "疑似类型", "置信度"],
      "rows": [["unknown.pdf", "文本...", "合同", "中"]]
    }
  }
}
```

**Excel 结构**:
- **汇总 Sheet**(首页): 统计各分类组文件数量和提取字段
- **数据 Sheet**(每子类型一个): 蓝色表头 + 自动筛选 + 冻结首行 + 自动列宽

## 依赖安装

```bash
# 安装所有 Python 依赖（requirements.txt 位于 scripts 目录下）
pip install -r <项目根目录>/scripts/requirements.txt

# 系统依赖(仅本地解析需要)
# macOS
brew install tesseract tesseract-lang

# Ubuntu/Debian
sudo apt-get install tesseract-ocr tesseract-ocr-chi-sim tesseract-ocr-eng
```

**核心 Python 依赖**:
- 本地解析: `PyMuPDF`, `python-docx`, `openpyxl`, `xlrd`, `pandas`, `pytesseract`, `Pillow`
- 远程 OCR: `requests`, `pyyaml`
- Excel 生成: `openpyxl>=3.1.0`

## 注意事项

1. **模式判定优先级**: 严格按"模式判定规则"表格判断,不确定的话**优先使用模式 A**,然后询问用户是否需要生成 Excel
2. **数据真实性**: Step 2 字段提取严禁杜撰,所有数据必须来源于 Step 1 的 `parsedText`
3. **字段缺失处理**: 如果文本中不存在某字段,留空(`""`),不要编造
4. **分类容错**: 无法匹配预定义分类的文档,优先尝试动态提取(5+ 字段),失败后归入"未识别"类
5. **动态提取确认**: 未知文档提取 5+ 字段时,**必须**使用 AskUserQuestion 让用户确认
6. **输出路径**: 所有输出文件默认在 `output/` 目录,带时间戳避免覆盖
7. **并发限制**: 远程 OCR 最大并发 10 个文件,本地解析最大并行 10 个文件
8. **文件大小**: 单文件最大 10MB(远程 OCR 限制)
9. **OCR 降级策略**: 本地解析 PDF 提取文本 < 50 字符时,自动降级到 Tesseract OCR;仍失败则尝试远程 OCR



path: references/insight/module-data-insight.md
content: # 数据解读模块 (Data Insight Module)

通过 Quick BI 小Q解读开放 API，将上传的 Excel 文件（.xls / .xlsx）解析为 Markdown 表格，经 base64 编码后发送至数据解读流式接口，生成深度分析结果。

> 配置说明请参见主文件的「配置」章节。

## 环境依赖

安装命令：

```bash
pip install requests pyyaml openpyxl xlrd
```

| 依赖包 | 必要性 | 用途 |
|--------|--------|------|
| `requests` | **必需** | HTTP 请求（OpenAPI 调用、SSE 流式请求） |
| `pyyaml` | **必需** | 读取 `config.yaml` 配置文件 |
| `openpyxl` | **必需**（`.xlsx` 文件） | 解析 Office Open XML 格式的 Excel 文件 |
| `xlrd` | **必需**（`.xls` 文件） | 解析旧版 Excel 97-2003 格式的文件 |

# 前置条件

- 用户提供 `.xls` 或 `.xlsx` 格式的 Excel 数据文件
- 当数据超限或存在多文件时，Agent 必须先执行数据预处理（详见下方「数据过滤」章节）

## 数据过滤

**当满足以下任一条件时，Agent 必须在调用脚本前执行数据过滤；否则可直接调用脚本，跳过过滤：**
- 用户传入**多份 Excel 文件**
- 单个 Excel 文件的数据量**可能超过 10 万字符**（大文件）
- 用户问题有**明确的过滤条件**（如特定地区、时间范围等）

> 脚本在数据超过 10 万字符时会直接报错退出（不会截断），要求 Agent 先完成过滤后重新调用。

过滤步骤：
1. 用 Python（pandas / openpyxl）读取 Excel 文件
2. 根据用户问题，只保留与问题相关的行和列
3. 将过滤后的数据另存为新的 Excel 文件
4. 用新文件调用本脚本

```python
# 示例：用户问"华东地区销售情况"，先过滤出华东数据
import pandas as pd

df = pd.read_excel("/path/to/data.xlsx")
df_filtered = df[df["地区"] == "华东"]
df_filtered.to_excel("/tmp/filtered_data.xlsx", index=False)
# 然后用 filtered_data.xlsx 调用本脚本
```

> 若数据超限但用户问题是泛泛的"解读这份数据"，没有明确过滤条件，则保留全部列，按行截取前 N 行（确保转 Markdown 后不超过 10 万字符），并将截取后的数据另存为新文件调用。

## 数据量处理策略

脚本单次调用仅支持 **一个 Excel 文件**，且最大支持 **10 万字符**的 Markdown 数据。**超限时脚本会报错退出，不会截断数据。** 当数据超过此限制或用户传入多份 Excel 时，**调用方（Agent）必须在调用脚本之前**完成预处理。

> **用户可见性约束：**
> - 多文件合并时，向用户输出："正在整合多份数据并进行综合分析..."
> - 数据过滤时，向用户输出："正在根据您的问题进行数据过滤，缩小分析范围..."
> - 分批调用时，向用户输出："正在对数据进行分批解读并汇总结果..."
> - **禁止**向用户暴露数据量限制、字符数上限、调用失败等技术细节

### 多文件处理（用户传入多份 Excel 时必须执行）

脚本只接受单个 `--excel-file`，因此多文件场景需先合并。具体步骤：
1. 用 Python 分别读取每份 Excel
2. 根据用户问题，对每份数据进行行/列过滤，只保留与问题相关的数据
3. 将过滤后的多份数据合并为一个 Excel 文件
4. 用合并后的文件调用本技能

```python
# 示例：用户传入 3 份文件，问"华东地区销售情况"
import pandas as pd

files = ["/path/to/sales_2023.xlsx", "/path/to/sales_2024.xlsx", "/path/to/sales_2025.xlsx"]
dfs = []
for f in files:
    df = pd.read_excel(f)
    # 根据用户问题过滤相关数据
    if "地区" in df.columns:
        df = df[df["地区"] == "华东"]
    dfs.append(df)

merged = pd.concat(dfs, ignore_index=True)
merged.to_excel("/tmp/merged_data.xlsx", index=False)
# 然后用 merged_data.xlsx 调用本技能
```

#### 策略一：精细过滤（优先）

在前置过滤的基础上，若数据仍超限，进一步缩小过滤范围：减少保留的列数、增加过滤条件、限制时间范围等。

### 策略二：分批调用 + 汇总

若无法通过过滤缩减数据量，则将 Excel 按行拆分为多份（每份不超过 10 万字符），分批调用本技能，最后将各批次的解读结果合并为一份完整报告。

具体步骤：
1. 用 Python 读取 Excel，按行数拆分为多个子文件（每个子文件保留原始表头）
2. 对每个子文件依次调用 `python scripts/insight/q_insights.py "问题" --excel-file "/tmp/part_N.xlsx"`
3. 收集所有批次的解读结果
4. 将所有结果汇总，生成一份完整、连贯的分析报告，去除重复内容，保留所有关键数据和结论
5. 向用户仅输出最终合并后的报告，禁止展示中间分片结果

```python
# 示例：拆分大文件为多份
import pandas as pd
import math

df = pd.read_excel("/path/to/big_data.xlsx")
ROWS_PER_CHUNK = 500  # 根据列数调整，确保每份转 Markdown 后不超过 10 万字符
total_chunks = math.ceil(len(df) / ROWS_PER_CHUNK)

for i in range(total_chunks):
    chunk = df.iloc[i * ROWS_PER_CHUNK : (i + 1) * ROWS_PER_CHUNK]
    chunk.to_excel(f"/tmp/part_{i+1}.xlsx", index=False)
# 然后逐份调用本技能，最后汇总结果
```

## 工作流程

```mermaid
flowchart TB
  input["解读问题 + Excel 文件"] --> needFilter{"多文件 / 大文件 / 有明确过滤条件?"}
  needFilter -- 是 --> filter["Agent 根据用户问题过滤数据"]
  needFilter -- 否 --> parseExcel["解析 Excel → Markdown 表格"]
  filter --> saveFile["另存为新 Excel 文件"]
  saveFile --> parseExcel
  parseExcel --> checkLimit{"数据 ≤ 10万字符?"}
  checkLimit -- 是 --> encode["Markdown 文本 base64 编码"]
  checkLimit -- 否 --> errorExit["报错退出，要求过滤或分批"]
  encode --> interpret["POST 数据解读流式接口"]
  interpret --> parseSSE["实时解析 SSE 事件"]
  parseSSE --> reasoning["输出推理过程"]
  parseSSE --> text["输出解读结果"]
```

### 执行命令

```bash
# 通过 Excel 文件解读（支持 .xls 和 .xlsx）
python scripts/insight/q_insights.py "各分公司业绩有什么趋势？" --excel-file "/path/to/data.xlsx"
python scripts/insight/q_insights.py "这个报表有什么异常？" --excel-file "/path/to/data.xls"
```

### 内部处理流程

1. **解析 Excel 文件**：根据文件扩展名自动选择解析库（`.xlsx` → `openpyxl`，`.xls` → `xlrd`），支持多 Sheet，每个 Sheet 的第一行作为表头，转换为 Markdown 表格文本

2. **数据编码**：将 Markdown 表格文本进行 UTF-8 + base64 双重编码

3. **调用数据解读流式接口**：`POST /openapi/v2/smartq/dataInterpretationStream`，请求体为 JSON（`stringData`（base64 编码）、`userQuestion`、`modelCode`），响应为 SSE 事件流

**SSE 事件解析：**

- `reasoning` → 输出推理过程
- `text` / `summary` → 输出解读结果
- `finish` → 解读结束

## 输出说明

脚本运行时会实时输出以下内容：

- `[Excel]` Excel 文件解析状态
- `[推理过程]` AI 的分析推理
- `[解读结果]` 最终的数据解读内容
- `[完成]` 解读结束

## 关键接口

| 接口 | 方法 | Content-Type | 说明 |
|------|------|-------------|------|
| [REDACTED_SECRET] | POST | application/json | 数据解读流式接口，返回 SSE |

### 请求体字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `stringData` | string | Excel 解析后的 Markdown 文本，经 UTF-8 + base64 编码 |
| `userQuestion` | string | 用户的解读问题 |
| `modelCode` | string | 解读模型代码，当前为 `SYSTEM_deepseek-v3` |

## 重要提示

1. **Excel 文件格式**：支持 `.xls`（Excel 97-2003）和 `.xlsx`（Office Open XML），文件第一行作为表头
2. **数据编码**：Excel 解析后的 Markdown 文本需经 UTF-8 + base64 双重编码后作为 `stringData` 发送
3. **流式超时**：数据解读流默认超时 10 分钟（600 秒）
4. **数据量上限**：单次调用最大 10 万字符，超过时必须先过滤或拆分数据，禁止直接传入超限数据；**禁止向用户透出此上限值**
5. **用户可见性**：多文件合并时告知用户"正在整合多份数据"，过滤时告知"正在进行数据过滤"，分批时告知"正在分批解读并汇总"，禁止向用户暴露字符数限制、调用失败等技术细节
6. **userId 自动处理**：`user_token` 未配置时，脚本启动时即自动基于设备唯一标识生成 accountId，通过组织用户接口检查并注册用户，注册成功后将 userId 回写到全局配置 `~/.qbi/config.yaml`，后续调用不再重复注册

---

## 异常处理（必读）

脚本已内置以下异常的检测逻辑，会在控制台自动打印对应提示。Agent **必须原样转达**提示内容（含链接），不得省略、改写或仅输出通用错误信息。检测到任一异常时，**立即终止流程**。

[qr-group]: https://img.alicdn.com/imgextra/i4/O1CN01DYNgA71txOqnKPdQU_!!6000000005968-2-tps-836-706.png

### 1. 试用到期

**触发条件**：任何步骤的脚本输出或 API 响应中出现错误码 `AE0579100004`
**检测位置**：`scripts/common/utils.py` 中的 `check_trial_expired()`
**必须展示的提示**：

> 您的解读skill试用已到期。
> 点击链接即可领取30天延期试用，保留数据解读、洞察分析等能力。或点击加入交流群，获取最新资讯。
>
> 试用链接：https://bi.aliyun.com/console.htm
>
> ![交流群二维码][qr-group]

### 2. 用户自动注册失败

**触发条件**：脚本输出包含「自动注册用户失败」
**检测位置**：`scripts/common/utils.py` 中的 `_auto_provision_user()`
**必须展示的提示**：

> 自动注册用户失败，请手动在 ~/.qbi/config.yaml 中配置 user_token。
> 可通过 Quick BI 管理控制台获取用户 ID：https://bi.aliyun.com
>
> 如需帮助，请扫码加入交流群获取支持：
>
> ![交流群二维码][qr-group]


path: references/report/module-data-report.md
content: # 报告生成模块 (Data Report Module)

通过 Quick BI 开放API接口,实现与智能小Q的对话,支持生成数据分析报告功能。

> 配置说明请参见主文件的「配置」章节。

## 技能触发与区分规则

### 本模块 vs docx/xlsx/pdf 等原生文件处理技能
**当用户意图是「生成报告」时，本模块的优先级高于所有原生文件处理技能**。即使用户上传了`.pdf` `.docx` 或 `.xlsx` 文件，只要目标是生成分析报告，就必须使用本模块，而非 docx/xlsx/pdf 原生技能。
- 示例："请帮我结合这两份文件生成一份数据分析报告" → **本模块**
- 示例："基于上传的 Excel 和 Word 文件生成报告" → **本模块**
- 示例："汇总这几份数据，写一份复盘报告" → **本模块**

### 本模块 vs 问数模块（文件问数）
- 用户目标是生成「报告/文档/复盘」→ **本模块**
- 用户目标是「查数/问数/分析某个具体指标」→ 问数模块
- 示例："帮我基于这份数据生成一份分析报告" → **本模块**
- 示例："帮我分析这份数据，组件数量最多的产品TOP10" → 问数模块

### 本模块 vs 问数模块（数据集问数）
- 用户要生成报告文档 → **本模块**
- 用户没有文件，要查询 Quick BI 平台数据集 → 问数模块

## 工作流程

按以下步骤**分别执行**各脚本（不要使用一键脚本 `generate_report.py`），确保每一步都能实时展示中间结果：

```mermaid
flowchart LR
  userQuestion[用户问题] --> autoReg{"user_id\n已配置?"}
  autoReg -- 否 --> register["自动注册用户\n回填 config.yaml"]
  autoReg -- 是 --> uploadStep
  register -- 失败 --> abort["⛔ 终止流程\n告知原因 + 联系 Quick BI 服务"]
  register -- 成功 --> uploadStep["Step 1: 上传文件(可选)"]
  uploadStep --> resources["resources: id,title,type"]
  userQuestion --> createChat["Step 2: 创建报告会话"]
  resources --> createChat
  createChat --> pollResult["Step 3: 轮询 SSE 结果"]
  pollResult --> finishCheck{"type=qreport && function=qreportUsedToken"}
  finishCheck -->|是| reportUrl["Step 4: 输出 qreportReplay 链接"]
```

### Step 1：上传文件（可选）

用户上传文件时，先调用 `scripts/report/upload_reference_file.py` 上传每个文件。

```bash
python scripts/report/upload_reference_file.py "<文件1>" "<文件2>"
```

上传接口：**`POST /openapi/v2/qreport/uploadReferenceFile`**，表单字段：`file`（必填）、`chatType`（固定 `manus`）、`userId`（与 `config.yaml` 的 `user_id` 一致）。

上传结果需映射为会话参数 `resources`，每个资源对象只保留以下字段：

```json
[
  {
    "id": "fileId",
    "title": "fileName",
    "type": "fileType"
  }
]
```

文件格式支持 `doc`、`docx`、`xls`、`xlsx`、`csv`，单文件大小不超过 `10MB`（与 Quick BI 开放接口说明一致）。

### Step 2：创建报告会话

```bash
python scripts/report/create_chat.py "<用户问题>"
```

脚本会输出 `chatId`、`messageId`（不含回放链接），记录 `chatId` 用于下一步轮询；**`reportUrl` 仅在轮询正常完成时**（出现 `qreportUsedToken` 且无 `error`）由 `query_report_result.py` 输出。

如果 Step 1 上传了文件，通过 `--resources-json` 参数传入 resources：

```bash
python scripts/report/create_chat.py "<用户问题>" --resources-json '<resources JSON>'
```

**说明：**

- 创建会话接口：**`POST /openapi/v2/smartq/createQreportChat`**，请求体为 JSON，接口响应体直接返回 `chatId` 字符串
- 请求体始终包含 `"resources": []` 和 `"interruptFeedback": ""`（即使没有上传文件也要传空数组和空字符串）；上传文件后 `resources` 会被替换为实际文件列表
- **`attachment`**（必传）：JSON 字符串，结构为 `{"resource": {"files": [...], "pages": [], "cubes": [], "dashboardFiles": []}, "useOnlineSearch": true}`。`useOnlineSearch` 固定传 `true`；`pages`/`cubes`/`dashboardFiles` 固定传空数组；若有上传文件，`files` 中每个对象包含 `fileId`/`fileType`/`iconType`/`file.name`/`fileName`，若无则传空数组。完整示例见 `example/qreport_input_with_attachment.json`
- **`bizArgs`**（必传）：对象，至少包含 `qbiHost` 字段，取值为 `config.yaml` 中的 `server_domain`
- 完整传参样例见 `example/qreport_input.json`（无文件）和 `example/qreport_input_with_attachment.json`（含文件）
- `chatId` 是后续轮询的关键值，也是最终回放页的 `caseId`

### Step 3：轮询获取结果

使用 Step 2 返回的 `chatId` 开始轮询，脚本会实时打印增量内容：

```bash
python scripts/report/query_report_result.py "<chatId>"
```

轮询接口：**`GET /openapi/v2/smartq/qreportChatData`**，查询参数：`chatId`（会话 UUID）、`userId`（与 `config.yaml` 的 `user_id` 一致）。

轮询接口返回 **JSON 数组**，每个元素为 `{"data":"...", "type":"..."}`。返回结果模型见 `example/output_model.txt`，完整正常输出样例见 `example/qreport_output_data.json`。脚本会自动解析并持续输出新增内容。关注以下事件类型：

| type | 说明 |
|------|------|
| `trace` | 链路追踪 ID（如 UUID），脚本会输出 `[trace] ...` |
| `heartbeat` / `check` | 心跳与流控，脚本静默跳过 |
| `error` | 报告异常，**立即终止轮询**并输出错误信息和 trace；脚本会自动提示"当前报告生成失败，请联系产品服务同学排查问题。"（样例见 `example/qreport_output_error.json`） |
| `plan` | 规划阶段：`learn`(文件学习)、`thinking`(思考)、`mainText`(规划步骤)、`refuse`(拒识)、`interrupt`(确认) |
| `schedule` | 任务调度分析 |
| `step` | 执行步骤：包含 `id`/`title`/`desc` |
| `actionThinking` | 执行阶段思考(markdown) |
| `subStep` | 子步骤：`onlineSearchResult`(联网搜索)、`knowledgeBaseResult`(知识库)、`reasoning`(推理)、`structuredChart`/`unStructuredChart`(图表)、`sql`/`dsl`/`learn`/`rewrite`/`answer` 等 |
| `qreport` | 报告生成阶段：脚本**不输出**报告正文/图表/搜索结果等内容，仅提示「正在生成报告...」；`qreportUsedToken` 标志报告完成 |
| `finish` / `time` | 流结束标记，脚本静默跳过 |

**终止条件：**
- `type=qreport` 且 `function=qreportUsedToken`：报告正常生成完毕；**仅在此情况下**脚本会输出报告链接
- `type=error`：报告异常终止，**立即停止执行后续所有步骤**，输出错误信息和 trace，并提示用户"当前报告生成失败，请联系产品服务同学排查问题。"；**不输出**报告链接

### Step 4：输出报告链接（仅正常完成）

仅当满足上一节「`qreportUsedToken` 且无 `error`」时，轮询脚本才会输出：

```text
📊 报告链接

• 在线数据报告：
  点击查看完整报告: {server_domain}/copilot/qreportReplay?caseId=<chatId>
```

其中 `{server_domain}` 为配置文件中的 `server_domain` 值，`<chatId>` 为 Step 2 生成的 UUID（与 `caseId` 相同）。若因 `type=error` 结束，脚本不输出报告链接，请勿自行拼接或展示回放链接。

## 试用到期处理（必读）

**任何步骤**的脚本输出或 API 响应中出现错误码 **`AE0579100004`** 时，表示当前用户的分析 skill 试用已到期。此时**必须立即终止整个流程**，并向用户**完整展示**以下提示信息（不得省略、改写或截断）：

> 您的分析skill试用已到期。
> 点击链接即可领取30天延期试用，保留自动分析、报告生成等能力。或点击加入交流群，获取最新资讯。
>
> 试用链接：https://bi.aliyun.com/console.htm
> 交流群链接：https://img.alicdn.com/imgextra/i4/O1CN01JNZBQn1YhGxAi2R9B_!!6000000003090-2-tps-872-836.png

脚本已内置此错误码的检测逻辑（`scripts/common/utils.py` 中的 `check_trial_expired()`），会在控制台自动打印上述提示。agent 在答复用户时**必须原样转达**此提示内容（含两个链接），不得仅输出原始错误信息。

## 重要提示

1. **试用到期优先**：当检测到错误码 `AE0579100004` 时，必须优先向用户展示试用到期提示（见上方「试用到期处理」章节），不得仅输出通用错误信息
2. **分步执行**：必须按 Step 1 → Step 2 → Step 3 → Step 4 依次执行各脚本，**不要使用 `generate_report.py` 一键脚本**，否则会阻塞且无法实时展示中间结果
3. **禁止自行解析文件**：当用户输入中包含文件（Excel/CSV/Word 等）时，**必须严格按照工作流程通过 Step 1 的接口上传文件**，由小Q报告后端进行解析和分析。**绝对不要**自行读取、解析或分析文件内容（如用 pandas 读取 Excel、用 python 解析 CSV 等），所有文件处理均由 Quick BI 后端完成
4. **一次对话只创建一次报告**（极重要）：同一次对话中，**只允许调用一次 `create_chat.py`**；获取到 `chatId` 后，后续无论用户追问多少次、无论轮询是否超时或失败，都**必须复用该 `chatId`** 调用 `query_report_result.py` 继续轮询，**禁止重新调用 `create_chat.py` 创建新会话**
5. **运行中任务自动切换**（严格执行）：当 `createQreportChat` 接口返回格式为「当前用户已有运行中的任务，自动切换至该任务结果输出，问题：%s，chatId：%s」时，表示该用户已有正在运行的报告任务。此时**必须**：
   - **完整展示返回信息**：将接口返回的完整内容原样展示给用户（包含问题和 chatId），不得省略或改写
   - 使用返回结果中的 `chatId`（而非请求时传入的 chatId）进行后续轮询
   - 脚本已内置此逻辑，会自动解析并输出切换提示，agent 需将提示信息**完整转达**给用户
6. **轮询间隔**：默认 **每 10 秒**请求一次 `qreportChatData`（`utils.DEFAULT_POLL_INTERVAL_SECONDS = 10.0`）；可用 `python scripts/report/query_report_result.py "<chatId>" --poll-interval 5` 调整
7. **超时判定**：轮询总时间超过 30 分钟仍未返回结果则认为失败
8. **错误处理**：轮询结果中出现 `type=error` 时，脚本自动终止并输出错误信息和 trace，同时提示"当前报告生成失败，请联系产品服务同学排查问题。"。agent **必须立即停止执行后续步骤**，将错误信息和 trace 展示给用户，**不要**提供报告链接，**不要**重试或继续执行
9. **userId 自动处理**：`user_token` 未配置时，脚本启动时即自动基于设备唯一标识生成 accountId，通过组织用户接口检查并注册用户，注册成功后将 userId 回写到全局配置 `~/.qbi/config.yaml`，后续调用不再重复注册
10. **遇错即停**：任何步骤（用户注册、文件上传、创建会话、轮询结果）执行报错时，必须立即终止整个流程，向用户清晰说明报错原因，并提醒：「如需进一步帮助，请联系 Quick BI 产品服务同学获取支持。」不得跳过错误继续执行后续步骤

## 输出建议

- 创建会话后只输出 `chatId`、`messageId`，不要提前展示回放链接
- 轮询过程中实时输出思考、规划步骤和联网搜索等中间结果；报告正文/图表内容**不会输出**，仅提示「正在生成报告...」
- **仅正常完成**时脚本会输出报告链接（含 URL），agent 据此提示用户点击查看完整报告；脚本不再输出结果 JSON
- 失败时不要编造或拼接回放地址，直接展示脚本输出的错误信息和 trace，并告知用户"当前报告生成失败，请联系产品服务同学排查问题。"
- 如果上传了文件，上传后输出文件上传完成即可，`resources` 映射结果无须展示


path: scripts/chat/chart_renderer.py
content: # -*- coding: utf-8 -*-
"""
图表渲染器：基于 result 事件的结构化数据生成 matplotlib 图表。

支持图表类型（与 Quick BI 前端一致）：
  指标看板 | 线图 | 组合图
  柱图     | 堆积柱 | 百分比柱
  条形图   | 堆积条 | 百分比条
  排行榜   | 饼图   | 漏斗图
  散点图   | 气泡图
  分组柱状图（多指标默认）
"""

from __future__ import annotations

import glob
import os
import platform
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

try:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt
    import matplotlib.ticker as ticker
    import matplotlib.patches as mpatches
    import matplotlib.font_manager as fm
    import numpy as np
    HAS_MPL = True
except ImportError:
    HAS_MPL = False

# ── 调色板 ──────────────────────────────────────────────────

PALETTE = [
    "#5B8FF9", "#5AD8A6", "#F6BD16", "#E86452",
    "#6DC8EC", "#945FB9", "#FF9845", "#1E9493",
    "#FF99C3", "#269A99",
]

# ── chartType 映射 ──────────────────────────────────────────

_CHART_TYPE_MAP: Dict[str, str] = {
    "indicator":     "indicator",
    "indicator-card": "indicator",
    "line":          "line",
    "combo":         "combo",
    "combination":   "combo",
    "bar":           "bar",
    "column":        "bar",
    "grouped_bar":   "grouped_bar",
    "stacked_bar":   "stacked_bar",
    "stackedBar":    "stacked_bar",
    "percent_bar":   "percent_bar",
    "percentBar":    "percent_bar",
    "horizontal_bar":        "horizontal_bar",
    "horizontalBar":         "horizontal_bar",
    "strip":                 "horizontal_bar",
    "stacked_horizontal_bar":  "stacked_horizontal_bar",
    "stackedHorizontalBar":    "stacked_horizontal_bar",
    "percent_horizontal_bar":  "percent_horizontal_bar",
    "percentHorizontalBar":    "percent_horizontal_bar",
    "ranking":       "ranking",
    "rank":          "ranking",
    "ranking-list":  "ranking",
    "pie":           "pie",
    "funnel":        "funnel",
    "scatter":       "scatter",
    "bubble":        "bubble",
    # table 类型不映射为 bar，保留原值让 render_chart 函数处理
    "table":         "table",
    "wordCloud":     "bar",
}


# ── 密集数据阈值 ────────────────────────────────────────────

MAX_DISPLAY_ITEMS = 20
MAX_DISPLAY_ITEMS_HORIZONTAL = 30

# ── 公共工具 ────────────────────────────────────────────────

def _format_value(v: float) -> str:
    abs_v = abs(v)
    if abs_v >= 1e8:
        return f"{v / 1e8:.2f}亿"
    if abs_v >= 1e4:
        return f"{v / 1e4:.2f}万"
    if abs_v == int(abs_v):
        return str(int(v))
    return f"{v:.2f}"


# ── 中文字体候选列表（按优先级排列） ────────────────────────

_CJK_FONT_CANDIDATES = [
    "Hiragino Sans GB", "STHeiti", "Heiti TC",
    "PingFang HK", "Songti SC", "Arial Unicode MS",
    "PingFang SC", "Microsoft YaHei", "SimHei",
    "Noto Sans CJK SC", "Noto Sans SC",
    "WenQuanYi Micro Hei", "WenQuanYi Zen Hei",
    "Droid Sans Fallback", "Source Han Sans SC",
    "Source Han Sans CN",
]

# 系统字体目录：用于扫描未被 matplotlib 自动发现的 .ttf/.otf 文件
_EXTRA_FONT_DIRS = [
    "/usr/share/fonts",
    "/usr/local/share/fonts",
    os.path.expanduser("~/.local/share/fonts"),
    os.path.expanduser("~/.fonts"),
    "/System/Library/Fonts",
    "/Library/Fonts",
    os.path.expanduser("~/Library/Fonts"),
    # Windows
    os.path.expandvars(r"%WINDIR%\Fonts"),
    os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Windows\Fonts"),
]

_font_setup_done = False


def _scan_and_register_fonts():
    """扫描系统字体目录，将未被 matplotlib 发现的字体强制注册。"""
    registered = 0
    for font_dir in _EXTRA_FONT_DIRS:
        if not os.path.isdir(font_dir):
            continue
        for ext in ("**/*.ttf", "**/*.otf", "**/*.ttc"):
            for font_path in glob.glob(os.path.join(font_dir, ext), recursive=True):
                try:
                    fm.fontManager.addfont(font_path)
                    registered += 1
                except Exception:
                    pass
    return registered


def _find_available_cjk_font() -> Optional[str]:
    """从候选列表中找到第一个 matplotlib 可用的中文字体名称。"""
    available = {f.name for f in fm.fontManager.ttflist}
    for name in _CJK_FONT_CANDIDATES:
        if name in available:
            return name
    return None


def _test_cjk_render(font_name: str) -> bool:
    """快速测试字体能否真正渲染中文字符（排除字体名存在但字形缺失的情况）。"""
    try:
        prop = fm.FontProperties(family=font_name)
        font_path = fm.findfont(prop, fallback_to_default=False)
        if font_path and os.path.exists(font_path):
            return True
    except Exception:
        pass
    return False


def _setup_style():
    global _font_setup_done

    # 第一步：尝试从候选列表直接匹配
    chosen_font = _find_available_cjk_font()

    # 第二步：若无可用字体，强制扫描系统字体目录并重新匹配
    if not chosen_font:
        count = _scan_and_register_fonts()
        if count > 0:
            chosen_font = _find_available_cjk_font()

    # 第三步：若仍无匹配，尝试从已注册字体中找任意含 CJK 关键词的字体
    if not chosen_font:
        cjk_keywords = [
            "CJK", "Noto", "Han", "Hei", "Song", "Ming", "Fang",
            "YaHei", "Gothic", "Droid", "WenQuan",
        ]
        for f in fm.fontManager.ttflist:
            if any(kw.lower() in f.name.lower() for kw in cjk_keywords):
                if _test_cjk_render(f.name):
                    chosen_font = f.name
                    break

    # 构建最终字体列表：已验证可用的字体放最前面
    if chosen_font:
        font_list = [chosen_font] + [
            f for f in _CJK_FONT_CANDIDATES if f != chosen_font
        ] + ["sans-serif"]
    else:
        font_list = _CJK_FONT_CANDIDATES + ["sans-serif"]

    if not _font_setup_done:
        if chosen_font:
            print(f"[chart_renderer] 使用中文字体: {chosen_font}", file=sys.stderr)
        else:
            _sys_name = platform.system()
            if _sys_name == "Windows":
                _install_hint = (
                    "建议安装: 在 Windows 设置 → 时间和语言 → 语言 → "
                    "中文（简体）→ 安装语言包，或手动安装 SimHei / Microsoft YaHei 字体"
                )
            elif _sys_name == "Darwin":
                _install_hint = "建议安装: macOS 通常自带 PingFang SC，若缺失请通过字体册安装中文字体"
            else:
                _install_hint = (
                    "建议安装: yum install -y google-noto-sans-cjk-sc-fonts && fc-cache -fv"
                )
            print(
                "[chart_renderer] 警告: 未找到可用中文字体，图表中文可能显示异常。"
                f"{_install_hint}",
                file=sys.stderr,
            )
        _font_setup_done = True

    plt.rcParams.update({
        "font.sans-serif": font_list,
        "axes.unicode_minus": False,
        "figure.dpi": 150,
        "savefig.dpi": 150,
        "savefig.bbox": "tight",
        "savefig.pad_inches": 0.3,
    })


def _safe_float(v: Any) -> Optional[float]:
    if v is None or v == "-" or v == "":
        return None
    try:
        return float(v)
    except (ValueError, TypeError):
        return None


def _build_labels(rows: List[dict], dims: List[dict]) -> List[str]:
    dim_names = [d["fieldName"] for d in dims]
    labels = []
    for row in rows:
        parts = [str(row.get(dn, "")) for dn in dim_names]
        labels.append(" / ".join(parts) if len(parts) > 1 else parts[0])
    return labels


def _extract_metric_values(rows: List[dict], metric_name: str) -> List[Optional[float]]:
    return [_safe_float(r.get(metric_name)) for r in rows]


def _truncate_data(
    rows: List[dict],
    metrics: List[dict],
    limit: int,
) -> Tuple[List[dict], int]:
    """Truncate rows to *limit*, sorting by the first metric descending.

    Returns (truncated_rows, original_total).
    """
    total = len(rows)
    if total <= limit:
        return rows, total

    metric_name = metrics[0]["fieldName"]
    sorted_rows = sorted(rows, key=lambda r: _safe_float(r.get(metric_name)) or 0, reverse=True)
    return sorted_rows[:limit], total


def _apply_common_style(ax, title: str = "", ylabel: str = "", show_grid_y=True):
    if title:
        ax.set_title(title, fontsize=13, fontweight="bold", pad=12, color="#1e293b")
    if ylabel:
        ax.set_ylabel(ylabel, fontsize=9, color="#64748b")
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.spines["left"].set_color("#e2e8f0")
    ax.spines["bottom"].set_color("#e2e8f0")
    ax.tick_params(colors="#94a3b8")
    ax.set_axisbelow(True)
    if show_grid_y:
        ax.grid(axis="y", color="#f1f5f9", linewidth=0.8)


def _add_truncation_note(ax, shown: int, total: int, position: str = "bottom"):
    if shown >= total:
        return
    note = f"显示前 {shown} 条（共 {total} 条）"
    if position == "bottom":
        ax.annotate(
            note, xy=(0.5, -0.02), xycoords="axes fraction",
            ha="center", va="top", fontsize=8, color="#94a3b8",
            fontstyle="italic",
        )
    else:
        ax.annotate(
            note, xy=(0.98, 0.98), xycoords="axes fraction",
            ha="right", va="top", fontsize=8, color="#94a3b8",
            fontstyle="italic",
        )


def _save_and_close(fig, path: Path):
    fig.patch.set_facecolor("white")
    fig.tight_layout()
    fig.savefig(path)
    plt.close(fig)


# ── 图表类型自动推断 ────────────────────────────────────────

# 仅支持单指标的图表类型，多指标时需回退到自动推断
_SINGLE_METRIC_TYPES = {"indicator", "bar", "horizontal_bar", "ranking", "pie", "funnel"}


def _infer_chart_type(
    dims: List[dict],
    metrics: List[dict],
    chart_type_hint: str,
) -> str:
    mapped = _CHART_TYPE_MAP.get(chart_type_hint, "")
    n_dims = len(dims)
    n_metrics = len(metrics)

    # 服务端推荐 table 类型时，直接返回 table（后续会渲染为 Markdown 表格）
    if mapped == "table":
        return "table"

    # 服务端推荐的类型仅支持单指标，但实际有多指标时，回退到自动推断
    if mapped and not (n_metrics > 1 and mapped in _SINGLE_METRIC_TYPES):
        return mapped

    if n_dims == 0 and n_metrics >= 1:
        return "indicator"
    if n_dims >= 1 and n_metrics == 1:
        return "bar"
    if n_dims >= 1 and n_metrics > 1:
        return "grouped_bar"
    return "bar"


# ── 主入口 ──────────────────────────────────────────────────

def render_result_charts(
    result_data: dict,
    output_dir: str | Path,
    *,
    prefix: str = "chart",
) -> List[str]:
    if not HAS_MPL:
        return []

    _setup_style()
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    data_list = result_data.get("dataList", [])
    if not data_list:
        return []

    ts = int(time.time())
    chart_paths: List[str] = []

    for idx, dataset in enumerate(data_list, 1):
        rows = dataset.get("data", [])
        field_info = dataset.get("fieldInfo", [])
        title = dataset.get("title") or ""
        chart_type_hint = dataset.get("chartType") or ""
        if not rows or not field_info:
            continue

        dims = [f for f in field_info if f.get("role") == "dimension"]
        metrics = [f for f in field_info if f.get("role") == "metric"]
        if not metrics:
            continue

        ctype = _infer_chart_type(dims, metrics, chart_type_hint)
        path = output_dir / f"{prefix}_{ts}_{idx}.png"

        # table 类型不渲染图片，直接跳过（由调用方使用 Markdown 表格 fallback）
        if ctype == "table":
            continue

        renderer = _RENDERERS.get(ctype, _render_bar_chart)
        try:
            renderer(rows, dims, metrics, title, path)
        except Exception:
            _render_bar_chart(rows, dims, metrics, title, path)

        if path.exists():
            chart_paths.append(str(path))

    return chart_paths


# =====================================================================
#  各图表类型渲染函数
#  统一签名: (rows, dims, metrics, title, path)
# =====================================================================

# ── 1. 指标看板 ─────────────────────────────────────────────

def _render_indicator(rows, dims, metrics, title, path):
    metric_name = metrics[0]["fieldName"]
    value = _safe_float(rows[0].get(metric_name))
    if value is None:
        return

    display_title = title or metric_name
    display_value = _format_value(value)

    fig, ax = plt.subplots(figsize=(5, 3))
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    ax.axis("off")

    ax.text(0.5, 0.62, display_value, ha="center", va="center",
            fontsize=36, fontweight="bold", color="#1e293b")
    ax.text(0.5, 0.28, display_title, ha="center", va="center",
            fontsize=14, color="#64748b")

    _save_and_close(fig, path)


# ── 2. 柱图 ─────────────────────────────────────────────────

def _render_bar_chart(rows, dims, metrics, title, path):
    if not dims:
        _render_indicator(rows, dims, metrics, title, path)
        return

    total = len(rows)
    if total > MAX_DISPLAY_ITEMS:
        rows, total = _truncate_data(rows, metrics, MAX_DISPLAY_ITEMS_HORIZONTAL)
        return _render_bar_as_horizontal(rows, dims, metrics, title, path, total)

    metric = metrics[0]
    metric_name = metric["fieldName"]
    labels = _build_labels(rows, dims)
    values = _extract_metric_values(rows, metric_name)

    valid = [(l, v) for l, v in zip(labels, values) if v is not None]
    if not valid:
        return
    labels, values = zip(*valid)

    fig_w = min(16, max(6, len(labels) * 0.8))
    fig, ax = plt.subplots(figsize=(fig_w, 5))

    bars = ax.bar(range(len(labels)), values, color=PALETTE[0], width=0.6, edgecolor="white")
    show_val_labels = len(labels) <= 15
    if show_val_labels:
        for bar, val in zip(bars, values):
            ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(),
                    _format_value(val), ha="center", va="bottom", fontsize=8, color="#475569")

    _set_x_labels(ax, labels)
    _apply_common_style(ax, title or metric_name, metric_name)
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))

    _save_and_close(fig, path)


def _render_bar_as_horizontal(rows, dims, metrics, title, path, original_total):
    """Render a horizontal bar chart for dense data (auto-switched from vertical bar)."""
    metric = metrics[0]
    metric_name = metric["fieldName"]
    labels = _build_labels(rows, dims)
    values = _extract_metric_values(rows, metric_name)

    valid = [(l, v) for l, v in zip(labels, values) if v is not None]
    if not valid:
        return
    labels, values = zip(*valid)
    labels = list(reversed(labels))
    values = list(reversed(values))

    n = len(labels)
    fig_h = max(5, n * 0.38)
    fig, ax = plt.subplots(figsize=(10, fig_h))

    max_val = max(values) if values else 1
    colors = [PALETTE[0] if i >= n - 3 else PALETTE[0] for i in range(n)]
    alphas = [1.0 if i >= n - 3 else 0.75 for i in range(n)]

    for i, (label, val) in enumerate(zip(labels, values)):
        ax.barh(i, val, height=0.6, color=colors[i], alpha=alphas[i], edgecolor="white")
        ax.text(val + max_val * 0.01, i, _format_value(val),
                ha="left", va="center", fontsize=8, color="#475569")

    ax.set_yticks(range(n))
    ax.set_yticklabels(labels, fontsize=8)
    _apply_common_style(ax, title or metric_name, show_grid_y=False)
    ax.grid(axis="x", color="#f1f5f9", linewidth=0.8)
    ax.xaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    _add_truncation_note(ax, n, original_total, position="top")
    _save_and_close(fig, path)


# ── 3. 分组柱状图 ───────────────────────────────────────────

def _render_grouped_bar(rows, dims, metrics, title, path):
    if not dims:
        _render_indicator(rows, dims, metrics, title, path)
        return

    original_total = len(rows)
    if original_total > MAX_DISPLAY_ITEMS:
        rows, original_total = _truncate_data(rows, metrics, MAX_DISPLAY_ITEMS)

    labels = _build_labels(rows, dims)
    metric_names = [m["fieldName"] for m in metrics]
    data_matrix = [[_safe_float(r.get(mn)) for r in rows] for mn in metric_names]

    n_g = len(labels)
    n_m = len(metric_names)
    bw = 0.7 / n_m
    x = np.arange(n_g)

    fig_w = min(16, max(7, n_g * 1.2))
    fig, ax = plt.subplots(figsize=(fig_w, 5.5))

    show_val_labels = n_g <= 12

    for i, (mn, vals) in enumerate(zip(metric_names, data_matrix)):
        offset = (i - n_m / 2 + 0.5) * bw
        pv = [v if v is not None else 0 for v in vals]
        bars = ax.bar(x + offset, pv, bw, label=mn, color=PALETTE[i % len(PALETTE)], edgecolor="white")
        if show_val_labels:
            for bar, val in zip(bars, vals):
                if val is not None:
                    ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(),
                            _format_value(val), ha="center", va="bottom", fontsize=7, color="#475569")

    _set_x_labels(ax, labels)
    _apply_common_style(ax, title or "数据对比")
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    ax.legend(fontsize=8, loc="upper right", framealpha=0.9)
    _add_truncation_note(ax, n_g, original_total)
    _save_and_close(fig, path)


# ── 4. 堆积柱 ───────────────────────────────────────────────

def _render_stacked_bar(rows, dims, metrics, title, path):
    if not dims:
        return _render_indicator(rows, dims, metrics, title, path)

    original_total = len(rows)
    if original_total > MAX_DISPLAY_ITEMS:
        rows, original_total = _truncate_data(rows, metrics, MAX_DISPLAY_ITEMS)

    labels = _build_labels(rows, dims)
    metric_names = [m["fieldName"] for m in metrics]
    data_matrix = [[v if v is not None else 0 for v in _extract_metric_values(rows, mn)] for mn in metric_names]

    n = len(labels)
    x = np.arange(n)
    fig_w = min(16, max(6, n * 0.9))
    fig, ax = plt.subplots(figsize=(fig_w, 5.5))

    show_val_labels = n <= 15

    bottom = np.zeros(n)
    for i, (mn, vals) in enumerate(zip(metric_names, data_matrix)):
        arr = np.array(vals)
        ax.bar(x, arr, 0.6, bottom=bottom, label=mn, color=PALETTE[i % len(PALETTE)], edgecolor="white")
        if show_val_labels:
            for j, (xi, v) in enumerate(zip(x, arr)):
                if v != 0:
                    ax.text(xi, bottom[j] + v / 2, _format_value(v), ha="center", va="center", fontsize=7, color="white")
        bottom += arr

    _set_x_labels(ax, labels)
    _apply_common_style(ax, title or "堆积柱状图")
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    ax.legend(fontsize=8, loc="upper right", framealpha=0.9)
    _add_truncation_note(ax, n, original_total)
    _save_and_close(fig, path)


# ── 5. 百分比柱 ─────────────────────────────────────────────

def _render_percent_bar(rows, dims, metrics, title, path):
    if not dims:
        return _render_indicator(rows, dims, metrics, title, path)

    original_total = len(rows)
    if original_total > MAX_DISPLAY_ITEMS:
        rows, original_total = _truncate_data(rows, metrics, MAX_DISPLAY_ITEMS)

    labels = _build_labels(rows, dims)
    metric_names = [m["fieldName"] for m in metrics]
    data_matrix = [np.array([v if v is not None else 0 for v in _extract_metric_values(rows, mn)]) for mn in metric_names]

    totals = sum(data_matrix)
    totals[totals == 0] = 1
    pct_matrix = [arr / totals * 100 for arr in data_matrix]

    n = len(labels)
    x = np.arange(n)
    fig_w = min(16, max(6, n * 0.9))
    fig, ax = plt.subplots(figsize=(fig_w, 5.5))

    show_val_labels = n <= 15

    bottom = np.zeros(n)
    for i, (mn, pcts) in enumerate(zip(metric_names, pct_matrix)):
        ax.bar(x, pcts, 0.6, bottom=bottom, label=mn, color=PALETTE[i % len(PALETTE)], edgecolor="white")
        if show_val_labels:
            for j, (xi, p) in enumerate(zip(x, pcts)):
                if p > 5:
                    ax.text(xi, bottom[j] + p / 2, f"{p:.0f}%", ha="center", va="center", fontsize=7, color="white")
        bottom += pcts

    ax.set_ylim(0, 100)
    _set_x_labels(ax, labels)
    _apply_common_style(ax, title or "百分比柱状图")
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f"{x:.0f}%"))
    ax.legend(fontsize=8, loc="upper right", framealpha=0.9)
    _add_truncation_note(ax, n, original_total)
    _save_and_close(fig, path)


# ── 6. 条形图（水平柱） ────────────────────────────────────

def _render_horizontal_bar(rows, dims, metrics, title, path):
    if not dims:
        return _render_indicator(rows, dims, metrics, title, path)

    original_total = len(rows)
    if original_total > MAX_DISPLAY_ITEMS_HORIZONTAL:
        rows, original_total = _truncate_data(rows, metrics, MAX_DISPLAY_ITEMS_HORIZONTAL)

    metric = metrics[0]
    metric_name = metric["fieldName"]
    labels = _build_labels(rows, dims)
    values = _extract_metric_values(rows, metric_name)

    valid = [(l, v) for l, v in zip(labels, values) if v is not None]
    if not valid:
        return
    labels, values = zip(*valid)
    labels = list(reversed(labels))
    values = list(reversed(values))

    n = len(labels)
    fig_h = max(4, n * 0.38)
    fig, ax = plt.subplots(figsize=(10, fig_h))

    y = range(n)
    bars = ax.barh(y, values, color=PALETTE[0], height=0.6, edgecolor="white")
    for bar, val in zip(bars, values):
        ax.text(bar.get_width(), bar.get_y() + bar.get_height() / 2,
                " " + _format_value(val), ha="left", va="center", fontsize=8, color="#475569")

    ax.set_yticks(y)
    ax.set_yticklabels(labels, fontsize=8)
    _apply_common_style(ax, title or metric_name, show_grid_y=False)
    ax.grid(axis="x", color="#f1f5f9", linewidth=0.8)
    ax.xaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    _add_truncation_note(ax, n, original_total, position="top")
    _save_and_close(fig, path)


# ── 7. 堆积条（水平堆积） ──────────────────────────────────

def _render_stacked_horizontal_bar(rows, dims, metrics, title, path):
    if not dims:
        return _render_indicator(rows, dims, metrics, title, path)

    labels = list(reversed(_build_labels(rows, dims)))
    metric_names = [m["fieldName"] for m in metrics]
    data_matrix = [np.array(list(reversed([v if v is not None else 0 for v in _extract_metric_values(rows, mn)]))) for mn in metric_names]

    y = np.arange(len(labels))
    fig, ax = plt.subplots(figsize=(8, max(4, len(labels) * 0.45)))

    left = np.zeros(len(labels))
    for i, (mn, vals) in enumerate(zip(metric_names, data_matrix)):
        ax.barh(y, vals, 0.6, left=left, label=mn, color=PALETTE[i % len(PALETTE)], edgecolor="white")
        for j, (yi, v) in enumerate(zip(y, vals)):
            if v != 0:
                ax.text(left[j] + v / 2, yi, _format_value(v), ha="center", va="center", fontsize=7, color="white")
        left += vals

    ax.set_yticks(y)
    ax.set_yticklabels(labels, fontsize=9)
    _apply_common_style(ax, title or "堆积条形图", show_grid_y=False)
    ax.grid(axis="x", color="#f1f5f9", linewidth=0.8)
    ax.legend(fontsize=8, loc="lower right", framealpha=0.9)
    _save_and_close(fig, path)


# ── 8. 百分比条 ─────────────────────────────────────────────

def _render_percent_horizontal_bar(rows, dims, metrics, title, path):
    if not dims:
        return _render_indicator(rows, dims, metrics, title, path)

    labels = list(reversed(_build_labels(rows, dims)))
    metric_names = [m["fieldName"] for m in metrics]
    data_matrix = [np.array(list(reversed([v if v is not None else 0 for v in _extract_metric_values(rows, mn)]))) for mn in metric_names]

    totals = sum(data_matrix)
    totals[totals == 0] = 1
    pct_matrix = [arr / totals * 100 for arr in data_matrix]

    y = np.arange(len(labels))
    fig, ax = plt.subplots(figsize=(8, max(4, len(labels) * 0.45)))

    left = np.zeros(len(labels))
    for i, (mn, pcts) in enumerate(zip(metric_names, pct_matrix)):
        ax.barh(y, pcts, 0.6, left=left, label=mn, color=PALETTE[i % len(PALETTE)], edgecolor="white")
        for j, (yi, p) in enumerate(zip(y, pcts)):
            if p > 5:
                ax.text(left[j] + p / 2, yi, f"{p:.0f}%", ha="center", va="center", fontsize=7, color="white")
        left += pcts

    ax.set_xlim(0, 100)
    ax.set_yticks(y)
    ax.set_yticklabels(labels, fontsize=9)
    _apply_common_style(ax, title or "百分比条形图", show_grid_y=False)
    ax.xaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: f"{x:.0f}%"))
    ax.legend(fontsize=8, loc="lower right", framealpha=0.9)
    _save_and_close(fig, path)


# ── 9. 排行榜 ───────────────────────────────────────────────

def _render_ranking(rows, dims, metrics, title, path):
    if not dims:
        return _render_indicator(rows, dims, metrics, title, path)

    original_total = len(rows)
    if original_total > MAX_DISPLAY_ITEMS_HORIZONTAL:
        rows, original_total = _truncate_data(rows, metrics, MAX_DISPLAY_ITEMS_HORIZONTAL)

    metric = metrics[0]
    metric_name = metric["fieldName"]
    labels = _build_labels(rows, dims)
    values = _extract_metric_values(rows, metric_name)

    valid = [(l, v) for l, v in zip(labels, values) if v is not None]
    if not valid:
        return
    labels, values = zip(*valid)

    labels = list(reversed(labels))
    values = list(reversed(values))
    n = len(labels)
    max_val = max(values) if values else 1

    fig_h = max(4, n * 0.42)
    fig, ax = plt.subplots(figsize=(10, fig_h))

    y = range(n)
    for i, (label, val) in enumerate(zip(labels, values)):
        rank = n - i
        color = PALETTE[0] if rank <= 3 else "#cbd5e1"
        bar_alpha = 1.0 if rank <= 3 else 0.6

        ax.barh(i, val, height=0.55, color=color, alpha=bar_alpha, edgecolor="white")

        ax.text(-max_val * 0.02, i, f"{rank}", ha="right", va="center",
                fontsize=10, fontweight="bold", color=PALETTE[0] if rank <= 3 else "#94a3b8")
        ax.text(val + max_val * 0.01, i, _format_value(val), ha="left", va="center",
                fontsize=8, color="#475569")

    ax.set_yticks(y)
    ax.set_yticklabels(labels, fontsize=8)
    ax.set_xlim(-max_val * 0.05, max_val * 1.18)

    _apply_common_style(ax, title or metric_name, show_grid_y=False)
    ax.spines["left"].set_visible(False)
    ax.spines["bottom"].set_visible(False)
    ax.tick_params(left=False, bottom=False, labelbottom=False)
    ax.grid(False)
    _add_truncation_note(ax, n, original_total, position="top")
    _save_and_close(fig, path)


# ── 10. 线图 ────────────────────────────────────────────────

def _render_line(rows, dims, metrics, title, path):
    if not dims:
        return _render_indicator(rows, dims, metrics, title, path)

    original_total = len(rows)
    if original_total > MAX_DISPLAY_ITEMS * 2:
        rows = rows[:MAX_DISPLAY_ITEMS * 2]

    labels = _build_labels(rows, dims)
    n = len(labels)
    x = np.arange(n)

    fig_w = min(18, max(6, n * 0.6))
    fig, ax = plt.subplots(figsize=(fig_w, 5))

    show_val_labels = n <= 15
    marker_size = 5 if n <= 20 else (3 if n <= 30 else 0)

    for i, m in enumerate(metrics):
        mn = m["fieldName"]
        values = _extract_metric_values(rows, mn)
        plot_vals = [v if v is not None else float("nan") for v in values]
        color = PALETTE[i % len(PALETTE)]
        ax.plot(x, plot_vals, marker="o" if marker_size > 0 else None,
                markersize=marker_size, linewidth=2, color=color, label=mn)
        if show_val_labels:
            for xi, val in zip(x, values):
                if val is not None:
                    ax.text(xi, val, _format_value(val), ha="center", va="bottom", fontsize=7, color=color)

    _set_x_labels(ax, labels)
    _apply_common_style(ax, title or metrics[0]["fieldName"])
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    if len(metrics) > 1:
        ax.legend(fontsize=8, loc="upper right", framealpha=0.9)
    _add_truncation_note(ax, n, original_total)
    _save_and_close(fig, path)


# ── 11. 组合图（第一个指标柱图，其余线图） ──────────────────

def _render_combo(rows, dims, metrics, title, path):
    if not dims or len(metrics) < 2:
        return _render_line(rows, dims, metrics, title, path)

    original_total = len(rows)
    if original_total > MAX_DISPLAY_ITEMS:
        rows, original_total = _truncate_data(rows, metrics, MAX_DISPLAY_ITEMS)

    labels = _build_labels(rows, dims)
    n = len(labels)
    x = np.arange(n)

    fig_w = min(16, max(7, n * 0.9))
    fig, ax1 = plt.subplots(figsize=(fig_w, 5.5))

    bar_metric = metrics[0]
    bar_vals = [v if v is not None else 0 for v in _extract_metric_values(rows, bar_metric["fieldName"])]
    ax1.bar(x, bar_vals, 0.5, color=PALETTE[0], alpha=0.7, label=bar_metric["fieldName"], edgecolor="white")
    ax1.set_ylabel(bar_metric["fieldName"], fontsize=9, color=PALETTE[0])
    ax1.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))

    marker_size = 4 if n <= 20 else 0
    ax2 = ax1.twinx()
    for i, m in enumerate(metrics[1:], 1):
        mn = m["fieldName"]
        vals = [v if v is not None else float("nan") for v in _extract_metric_values(rows, mn)]
        color = PALETTE[i % len(PALETTE)]
        ax2.plot(x, vals, marker="o" if marker_size > 0 else None,
                 markersize=marker_size, linewidth=2, color=color, label=mn)
    ax2.set_ylabel(metrics[1]["fieldName"], fontsize=9, color=PALETTE[1])
    ax2.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))

    _set_x_labels(ax1, labels)
    _apply_common_style(ax1, title or "组合图")

    lines1, labels1 = ax1.get_legend_handles_labels()
    lines2, labels2 = ax2.get_legend_handles_labels()
    ax1.legend(lines1 + lines2, labels1 + labels2, fontsize=8, loc="upper right", framealpha=0.9)
    ax2.spines["top"].set_visible(False)

    _add_truncation_note(ax1, n, original_total)
    _save_and_close(fig, path)


# ── 12. 饼图 ────────────────────────────────────────────────

_MAX_PIE_SLICES = 10

def _render_pie(rows, dims, metrics, title, path):
    metric = metrics[0]
    metric_name = metric["fieldName"]

    if dims:
        labels = _build_labels(rows, dims)
    else:
        labels = [metric_name]

    values = _extract_metric_values(rows, metric_name)
    valid = [(l, v) for l, v in zip(labels, values) if v is not None and v > 0]
    if not valid:
        return
    labels, values = zip(*valid)

    if len(labels) > _MAX_PIE_SLICES:
        pairs = sorted(zip(labels, values), key=lambda x: x[1], reverse=True)
        top = pairs[:_MAX_PIE_SLICES - 1]
        others_sum = sum(v for _, v in pairs[_MAX_PIE_SLICES - 1:])
        labels = [l for l, _ in top] + ["其他"]
        values = [v for _, v in top] + [others_sum]

    colors = [PALETTE[i % len(PALETTE)] for i in range(len(labels))]

    fig, ax = plt.subplots(figsize=(7, 5.5))
    wedges, texts, autotexts = ax.pie(
        values,
        labels=None,
        autopct=lambda pct: f"{pct:.1f}%" if pct > 3 else "",
        colors=colors,
        startangle=90,
        pctdistance=0.75,
        wedgeprops={"edgecolor": "white", "linewidth": 1.5},
    )
    for t in autotexts:
        t.set_fontsize(8)
        t.set_color("white")
        t.set_fontweight("bold")

    legend_labels = [f"{l}  {_format_value(v)}" for l, v in zip(labels, values)]
    ax.legend(wedges, legend_labels, loc="center left", bbox_to_anchor=(1.05, 0.5),
              fontsize=8, frameon=False)

    display_title = title or metric_name
    ax.set_title(display_title, fontsize=13, fontweight="bold", pad=12, color="#1e293b")

    _save_and_close(fig, path)


# ── 13. 漏斗图 ──────────────────────────────────────────────

def _render_funnel(rows, dims, metrics, title, path):
    metric = metrics[0]
    metric_name = metric["fieldName"]

    if dims:
        labels = _build_labels(rows, dims)
    else:
        labels = [str(i + 1) for i in range(len(rows))]

    values = _extract_metric_values(rows, metric_name)
    valid = [(l, v) for l, v in zip(labels, values) if v is not None and v > 0]
    if not valid:
        return
    labels, values = zip(*valid)

    max_val = max(values)
    n = len(labels)

    fig, ax = plt.subplots(figsize=(8, max(4, n * 0.7)))
    ax.set_xlim(0, 1)
    ax.set_ylim(0, n)
    ax.axis("off")

    for i, (label, val) in enumerate(zip(labels, values)):
        width = val / max_val * 0.8
        left = (1 - width) / 2
        y_bottom = n - i - 1
        color = PALETTE[i % len(PALETTE)]

        rect = mpatches.FancyBboxPatch(
            (left, y_bottom + 0.1), width, 0.75,
            boxstyle="round,pad=0.02", facecolor=color, edgecolor="white", linewidth=1.5,
        )
        ax.add_patch(rect)

        ax.text(0.5, y_bottom + 0.48, f"{label}    {_format_value(val)}",
                ha="center", va="center", fontsize=10, color="white", fontweight="bold")

    display_title = title or metric_name
    ax.set_title(display_title, fontsize=13, fontweight="bold", pad=8, color="#1e293b")
    _save_and_close(fig, path)


# ── 14. 散点图 ──────────────────────────────────────────────

def _render_scatter(rows, dims, metrics, title, path):
    if len(metrics) < 2:
        return _render_bar_chart(rows, dims, metrics, title, path)

    x_metric = metrics[0]["fieldName"]
    y_metric = metrics[1]["fieldName"]
    x_vals = _extract_metric_values(rows, x_metric)
    y_vals = _extract_metric_values(rows, y_metric)

    labels = _build_labels(rows, dims) if dims else [None] * len(rows)

    valid = [(xv, yv, l) for xv, yv, l in zip(x_vals, y_vals, labels) if xv is not None and yv is not None]
    if not valid:
        return
    xs, ys, ls = zip(*valid)

    fig, ax = plt.subplots(figsize=(7, 5.5))
    ax.scatter(xs, ys, s=60, c=PALETTE[0], alpha=0.7, edgecolors="white", linewidths=0.5)

    for xv, yv, l in zip(xs, ys, ls):
        if l:
            ax.annotate(l, (xv, yv), textcoords="offset points", xytext=(5, 5), fontsize=7, color="#64748b")

    ax.set_xlabel(x_metric, fontsize=10, color="#475569")
    ax.set_ylabel(y_metric, fontsize=10, color="#475569")
    _apply_common_style(ax, title or "散点图")
    ax.xaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    _save_and_close(fig, path)


# ── 15. 气泡图 ──────────────────────────────────────────────

def _render_bubble(rows, dims, metrics, title, path):
    if len(metrics) < 2:
        return _render_scatter(rows, dims, metrics, title, path)

    x_metric = metrics[0]["fieldName"]
    y_metric = metrics[1]["fieldName"]
    size_metric = metrics[2]["fieldName"] if len(metrics) >= 3 else None

    x_vals = _extract_metric_values(rows, x_metric)
    y_vals = _extract_metric_values(rows, y_metric)
    size_vals = _extract_metric_values(rows, size_metric) if size_metric else [None] * len(rows)

    labels = _build_labels(rows, dims) if dims else [None] * len(rows)

    valid = [(xv, yv, sv, l) for xv, yv, sv, l in zip(x_vals, y_vals, size_vals, labels)
             if xv is not None and yv is not None]
    if not valid:
        return
    xs, ys, ss, ls = zip(*valid)

    if any(s is not None for s in ss):
        s_arr = np.array([s if s is not None else 0 for s in ss])
        max_s = s_arr.max() if s_arr.max() > 0 else 1
        sizes = (s_arr / max_s) * 800 + 30
    else:
        sizes = [120] * len(xs)

    fig, ax = plt.subplots(figsize=(7, 5.5))
    ax.scatter(xs, ys, s=sizes, c=PALETTE[0], alpha=0.5, edgecolors=PALETTE[0], linewidths=1)

    for xv, yv, l in zip(xs, ys, ls):
        if l:
            ax.annotate(l, (xv, yv), textcoords="offset points", xytext=(5, 5), fontsize=7, color="#64748b")

    ax.set_xlabel(x_metric, fontsize=10, color="#475569")
    ax.set_ylabel(y_metric, fontsize=10, color="#475569")
    _apply_common_style(ax, title or "气泡图")
    ax.xaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    ax.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, _: _format_value(x)))
    _save_and_close(fig, path)


# ── X 轴标签辅助 ───────────────────────────────────────────

def _set_x_labels(ax, labels):
    n = len(labels)
    ax.set_xticks(range(n))

    if n <= 6:
        ax.set_xticklabels(labels, fontsize=9, rotation=0, ha="center")
    elif n <= 15:
        ax.set_xticklabels(labels, fontsize=8, rotation=30, ha="right")
    elif n <= MAX_DISPLAY_ITEMS:
        ax.set_xticklabels(labels, fontsize=7, rotation=45, ha="right")
    else:
        step = max(1, n // 15)
        display = [labels[i] if i % step == 0 else "" for i in range(n)]
        ax.set_xticklabels(display, fontsize=7, rotation=45, ha="right")


# ── 渲染器注册表 ───────────────────────────────────────────

_RENDERERS = {
    "indicator":               _render_indicator,
    "bar":                     _render_bar_chart,
    "grouped_bar":             _render_grouped_bar,
    "stacked_bar":             _render_stacked_bar,
    "percent_bar":             _render_percent_bar,
    "horizontal_bar":          _render_horizontal_bar,
    "stacked_horizontal_bar":  _render_stacked_horizontal_bar,
    "percent_horizontal_bar":  _render_percent_horizontal_bar,
    "ranking":                 _render_ranking,
    "line":                    _render_line,
    "combo":                   _render_combo,
    "pie":                     _render_pie,
    "funnel":                  _render_funnel,
    "scatter":                 _render_scatter,
    "bubble":                  _render_bubble,
}


# =====================================================================
#  单图表入口（供数据集问数 / bichart 模式使用）
# =====================================================================

def render_chart(chart_data: dict, output_dir: str | Path = ".") -> Optional[str]:
    """渲染单个图表数据为 PNG，返回文件路径；失败时返回 None。

    ``chart_data`` 结构与 ``render_result_charts`` 的 ``dataList`` 中的
    单个元素一致：``{data, fieldInfo, chartType, title, id?}``。
    """
    if not HAS_MPL:
        return None

    output_dir = Path(output_dir)
    wrapper = {"dataList": [chart_data]}
    prefix = f"chart_{chart_data.get('id', 'out')[:8]}"
    paths = render_result_charts(wrapper, output_dir, prefix=prefix)
    return paths[0] if paths else None


# =====================================================================
#  Markdown 表格回退（matplotlib 不可用时使用）
# =====================================================================

def chart_data_to_markdown(chart_data: dict) -> str:
    """将图表数据转为 Markdown 表格文本。"""
    data = chart_data.get("data", [])
    field_info = chart_data.get("fieldInfo", [])
    title = chart_data.get("title", "")

    if not data or not field_info:
        return f"**{title}**\n\n（无数据）"

    headers = [f.get("fieldName", f"列{i}") for i, f in enumerate(field_info)]
    lines: List[str] = [f"**{title}**\n"] if title else []
    lines.append("| " + " | ".join(headers) + " |")
    lines.append("|" + "|".join([" --- "] * len(headers)) + "|")

    for row in data[:50]:
        row_vals = [str(row.get(h, "")) for h in headers]
        lines.append("| " + " | ".join(row_vals) + " |")

    if len(data) > 50:
        lines.append(f"\n（共 {len(data)} 行，仅显示前 50 行）")

    return "\n".join(lines)


path: scripts/chat/html_snapshot.py
content: # -*- coding: utf-8 -*-
"""
HTML → PNG 截图工具（零额外依赖）。

利用系统已安装的 Chrome / Chromium / Edge 的 headless 模式将 HTML 文件截图为 PNG。
无需安装 Playwright、Selenium 或任何 pip/npm 包。

用法：
    from html_snapshot import html_to_png

    png_path = html_to_png("/path/to/chart.html")
    # 返回 PNG 路径，失败返回 None
"""

from __future__ import annotations

import os
import platform
import shutil
import subprocess
from pathlib import Path
from typing import Optional

_BROWSER_PATHS: dict[str, list[str]] = {
    "Darwin": [
        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
        "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
        "/Applications/Chromium.app/Contents/MacOS/Chromium",
        "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
        "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
    ],
    "Linux": [
        "google-chrome",
        "google-chrome-stable",
        "chromium-browser",
        "chromium",
        "microsoft-edge",
    ],
    "Windows": [
        os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
        os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
        os.path.expandvars(r"%LocalAppData%\Google\Chrome\Application\chrome.exe"),
        os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
        os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
    ],
}


def _find_browser() -> Optional[str]:
    """在系统中查找可用的 Chrome/Chromium/Edge 浏览器路径。"""
    sys_name = platform.system()
    candidates = _BROWSER_PATHS.get(sys_name, [])

    for path in candidates:
        if sys_name in ("Darwin", "Windows"):
            if Path(path).exists():
                return path
        else:
            # 使用 shutil.which() 替代 subprocess 调用 which 命令，跨平台兼容
            found = shutil.which(path)
            if found:
                return found
    return None


def html_to_png(
    html_path: str,
    *,
    output_path: Optional[str] = None,
    width: int = 800,
    height: int = 600,
) -> Optional[str]:
    """将 HTML 文件截图为 PNG。

    Args:
        html_path: HTML 文件的绝对路径
        output_path: PNG 输出路径，默认为同目录同名 .png
        width: 视口宽度
        height: 视口高度

    Returns:
        PNG 文件路径，浏览器不可用或截图失败时返回 None
    """
    browser = _find_browser()
    if not browser:
        return None

    html_path = str(Path(html_path).resolve())
    if output_path is None:
        output_path = str(Path(html_path).with_suffix(".png"))

    # 使用 Path.as_uri() 生成跨平台兼容的 file:// URL
    file_url = Path(html_path).as_uri()

    cmd = [
        browser,
        "--headless=new",
        "--disable-gpu",
        "--no-sandbox",
        "--disable-software-rasterizer",
        f"--window-size={width},{height}",
        f"--screenshot={output_path}",
        "--hide-scrollbars",
        file_url,
    ]

    try:
        result = subprocess.run(
            cmd, capture_output=True, text=True, timeout=15,
        )
        if Path(output_path).exists() and Path(output_path).stat().st_size > 0:
            return output_path
    except Exception:
        pass

    return None


path: scripts/report/create_chat.py
content: # -*- coding: utf-8 -*-
"""
创建小Q报告会话。
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import List, Optional

sys.path.insert(0, str(Path(__file__).parent.parent))

from common.utils import create_report_chat, read_config


def load_resources_from_args(resources_json: Optional[str], resources_file: Optional[str]) -> Optional[List[dict]]:
    """从命令行参数读取 resources。"""
    if resources_file:
        with open(resources_file, "r", encoding="utf-8") as file_handle:
            loaded = json.load(file_handle)
            if isinstance(loaded, dict):
                if "resources" in loaded:
                    return loaded["resources"]
                raise ValueError("resources-file 必须是 resources 数组，或包含 resources 字段的 JSON 对象")
            return loaded
    if resources_json:
        loaded = json.loads(resources_json)
        if isinstance(loaded, dict):
            if "resources" in loaded:
                return loaded["resources"]
            raise ValueError("resources-json 必须是 resources 数组，或包含 resources 字段的 JSON 对象")
        return loaded
    return None


def parse_args() -> argparse.Namespace:
    """解析命令行参数。"""
    parser = argparse.ArgumentParser(description="创建小Q报告会话")
    parser.add_argument("question", help="用户输入的问题")
    parser.add_argument("--resources-json", default=None, help="resources 的 JSON 字符串")
    parser.add_argument("--resources-file", default=None, help="包含 resources 的 JSON 文件")
    parser.add_argument("--chat-id", default=None, help="自定义 chatId，不传则自动生成")
    parser.add_argument("--message-id", default=None, help="自定义 messageId，不传则自动生成")
    parser.add_argument("--workspace-dir", default=None, help="用户工作目录路径")
    return parser.parse_args()


def main() -> int:
    """脚本入口。"""
    args = parse_args()

    if args.workspace_dir:
        from common.config_loader import set_workspace_dir
        set_workspace_dir(args.workspace_dir)

    config = read_config()
    resources = load_resources_from_args(args.resources_json, args.resources_file)

    result = create_report_chat(
        args.question,
        resources=resources,
        chat_id=args.chat_id,
        message_id=args.message_id,
        config=config,
    )

    print(f"chatId: {result['chatId']}")
    print(f"messageId: {result['messageId']}")
    print(
        json.dumps(
            {
                "chatId": result["chatId"],
                "messageId": result["messageId"],
                "statusCode": result["statusCode"],
                "response": result["response"],
            },
            ensure_ascii=False,
            indent=2,
        )
    )
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except Exception as exc:  # pragma: no cover - 终端脚本直接输出错误
        print(f"创建会话失败：{exc}", file=sys.stderr)
        raise SystemExit(1)


path: scripts/requirements.txt
content: requests
pyyaml
matplotlib
numpy
openpyxl
xlrd

PyMuPDF
python-docx
pandas
pytesseract
Pillow

Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)