# 增强 LLM 提取 Schema - 多模式业务逻辑自动化

> **Status:** Draft
> **Date:** 2026-04-17
> **Author:** Qoder

## Problem Statement

当前 `sg_scene_generate` 自动生成的 skill 脚本与 Claude 手写的 skill 存在显著差距：

### 差距清单

| 差距类型 | 描述 | 严重程度 |
|----------|------|----------|
| 多模式支持 | 无法识别 month/week 等多模式场景，只生成单一逻辑 | 🔴 高 |
| 多 API 端点 | 定义多个 API 但只使用第一个 | 🔴 高 |
| 请求格式检测 | 默认 JSON body，未检测 form-urlencoded | 🟡 中 |
| 数据归一化 | 简单映射，无关键字段验证和空行过滤 | 🟡 中 |
| 参数标准化 | 无参数标准化处理，直接透传 | 🟡 中 |
| 分页参数 | 未提取和处理分页参数 (rows/page/sidx/sord) | 🟡 中 |

### 对比分析

| 功能维度 | tq-lineloss-report (手写) | marketing-zero-consumer-report (自动) |
|----------|---------------------------|----------------------------------------|
| 模式切换 | month/week 两套完整逻辑 | 无 |
| API 端点 | queryMonthData + queryWeekData | 只用 API_ENDPOINTS[0] |
| 列定义 | MONTH/WEEK_COLUMN_DEFS 双套 | 单一 COLUMN_DEFS |
| 请求构建 | buildMonthRequest + buildWeekRequest | 单一 buildRequest |
| 请求格式 | form-urlencoded | application/json |
| 数据验证 | 关键字段验证 + filter(Boolean) | 无验证 |
| 参数标准化 | normalized 对象 + 类型转换 | 无 |

## Goal

增强 LLM 提取 Schema，使其能够从 index.html 中自动识别：

1. **多模式业务逻辑** (month/week 等)
2. **模式切换条件** (如何判断使用哪个模式)
3. **每个模式的专属配置** (API、列定义、请求格式、验证规则)
4. **数据归一化规则** (关键字段、过滤条件)

## Non-Goals

- 不改变现有的两阶段架构 (LLM 提取 → Rust 渲染)
- 不增加新的 CLI 参数
- 不支持超过 2 种模式的复杂场景 (Phase 1)
- 不处理认证/鉴权逻辑 (假设页面已登录)

## Architecture

### 增强后的 Schema 结构

```json
{
  "sceneId": "tq-lineloss-report",
  "sceneName": "台区线损报表",
  "sceneKind": "report_collection",

  "modes": [
    {
      "name": "month",
      "label": "月度报表",
      "condition": {
        "field": "period_mode",
        "operator": "equals",
        "value": "month"
      },
      "apiEndpoint": {
        "name": "月度线损查询",
        "url": "http://20.76.57.61:18080/gsllys/fourVerEightHor/fourVerEightHorLinelossRateList",
        "method": "POST",
        "contentType": "application/x-www-form-urlencoded"
      },
      "columnDefs": [
        ["ORG_NAME", "供电单位"],
        ["YGDL", "累计供电量"],
        ["YYDL", "累计售电量"],
        ["YXSL", "线损完成率(%)"],
        ["RAT_SCOPE", "线损率累计目标值"],
        ["BLANK3", "目标完成率"],
        ["BLANK2", "排行"]
      ],
      "requestTemplate": {
        "orgno": "${args.org_code}",
        "yn_flag": 0,
        "_search": false,
        "nd": "${Date.now()}",
        "rows": 1000,
        "page": 1,
        "sidx": "TO_NUMBER(ORG_NO)",
        "sord": "asc"
      },
      "normalizeRules": {
        "type": "validate_all_columns",
        "filterNull": true
      },
      "responsePath": "content"
    },
    {
      "name": "week",
      "label": "周报表",
      "condition": {
        "field": "period_mode",
        "operator": "equals",
        "value": "week"
      },
      "apiEndpoint": {
        "name": "周线损查询",
        "url": "http://20.76.57.61:18080/gsllys/tqLinelossStatis/getYearMonWeekLinelossAnalysisList",
        "method": "POST",
        "contentType": "application/x-www-form-urlencoded"
      },
      "columnDefs": [
        ["ORG_NAME", "供电单位"],
        ["LINE_LOSS_RATE", "综合线损率(%)"],
        ["PPQ", "供电量(Kwh)"],
        ["UPQ", "售电量(Kwh)"],
        ["LOSS_PQ", "损失电量(Kwh)"]
      ],
      "requestTemplate": {
        "orgno": "${args.org_code}",
        "tjzq": "week",
        "level": "00",
        "_search": false,
        "nd": "${Date.now()}",
        "rows": 1000,
        "page": 1,
        "sidx": "TO_NUMBER(ORG_NO)",
        "sord": "asc"
      },
      "normalizeRules": {
        "type": "validate_required",
        "requiredFields": ["ORG_NAME", "LINE_LOSS_RATE"],
        "filterNull": true
      },
      "responsePath": "content"
    }
  ],

  "defaultMode": "month",
  "modeSwitchField": "period_mode",

  "commonParams": {},
  "staticParams": {},

  "expectedDomain": "20.76.57.61",
  "targetUrl": "http://20.76.57.61:18080/gsllys"
}
```

### Schema 字段说明

#### 顶层字段

| 字段 | 类型 | 说明 |
|------|------|------|
| `modes` | Array | 模式配置数组 |
| `defaultMode` | string | 默认模式名称 |
| `modeSwitchField` | string | 用于切换模式的参数字段名 |

#### Mode 配置

| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | string | 模式标识符 |
| `label` | string | 模式显示名称 |
| `condition` | object | 模式激活条件 |
| `apiEndpoint` | object | 该模式的 API 配置 |
| `columnDefs` | Array | 该模式的列定义 |
| `requestTemplate` | object | 请求参数模板 |
| `normalizeRules` | object | 数据归一化规则 |
| `responsePath` | string | 响应数据路径 (如 `content`) |

#### Condition 结构

```json
{
  "field": "period_mode",      // 检查的字段
  "operator": "equals",        // 操作符: equals, not_equals, in, contains
  "value": "month"             // 比较值
}
```

#### NormalizeRules 结构

```json
{
  "type": "validate_required",  // validate_all_columns | validate_required
  "requiredFields": ["ORG_NAME", "LINE_LOSS_RATE"],  // 仅 validate_required 需要
  "filterNull": true            // 是否过滤空值行
}
```

### 数据流

```
用户请求 (args)
    ↓
validateArgs() → 参数验证
    ↓
detectMode(args[modeSwitchField]) → 检测当前模式
    ↓
selectModeConfig(mode) → 选择模式配置
    ↓
buildRequest(args, modeConfig) → 构建该模式的请求
    ↓
queryData(request, modeConfig.apiEndpoint) → HTTP 请求
    ↓
extractResponse(response, modeConfig.responsePath) → 提取数据
    ↓
normalizeRows(data, modeConfig.normalizeRules) → 数据归一化
    ↓
buildArtifact({ mode, columnDefs, rows, ... }) → 构建输出
```

## Implementation Details

### 1. 增强 LLM 提取 Prompt

修改 `frontend/scene-generator/llm-client.js` 中的 `DEEP_SYSTEM_PROMPT` 和 `buildDeepAnalyzePrompt`：

**关键 Prompt 增强：**

```
分析 index.html 中的业务逻辑模式：

1. **模式识别**
   - 查找条件分支逻辑 (if/switch) 中基于 period_mode、reportType 等字段的分支
   - 识别不同分支对应的 API 端点、列定义、请求格式

2. **API 提取**
   - 提取 $.ajax/fetch 调用中的 URL、method、contentType
   - 识别请求参数构造方式 (JSON.stringify vs 对象展开)
   - 检测分页参数 (rows/page/sidx/sord)

3. **请求格式检测**
   - contentType: application/json → JSON body
   - contentType: application/x-www-form-urlencoded → 对象展开
   - 无显式 contentType → 检查 data 参数格式

4. **数据归一化**
   - 查找数据渲染/表格填充逻辑
   - 识别字段映射关系
   - 检测数据验证条件 (哪些字段不能为空)

5. **响应路径**
   - 识别数据在响应中的位置 (response.content / response.data / response)
```

### 2. 增强 Rust Schema 结构

修改 `src/generated_scene/generator.rs`：

```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ModeConditionJson {
    pub field: String,
    #[serde(default)]
    pub operator: String,  // equals, not_equals, in, contains
    pub value: serde_json::Value,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct NormalizeRulesJson {
    #[serde(rename = "type")]
    pub rules_type: String,  // validate_all_columns, validate_required
    #[serde(default)]
    pub required_fields: Vec<String>,
    #[serde(default)]
    pub filter_null: bool,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ApiEndpointEnhancedJson {
    pub name: String,
    pub url: String,
    #[serde(default)]
    pub method: String,
    #[serde(default)]
    pub content_type: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ModeConfigJson {
    pub name: String,
    pub label: Option<String>,
    pub condition: ModeConditionJson,
    pub api_endpoint: ApiEndpointEnhancedJson,
    pub column_defs: Vec<(String, String)>,
    pub request_template: Option<serde_json::Value>,
    pub normalize_rules: Option<NormalizeRulesJson>,
    pub response_path: Option<String>,
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SceneInfoEnhancedJson {
    #[serde(rename = "sceneId")]
    pub scene_id: String,
    #[serde(rename = "sceneName")]
    pub scene_name: String,

    // 新增：多模式支持
    pub modes: Vec<ModeConfigJson>,
    pub default_mode: Option<String>,
    pub mode_switch_field: Option<String>,

    // 向后兼容：单模式场景
    #[serde(rename = "apiEndpoints", default)]
    pub api_endpoints: Vec<ApiEndpointJson>,
    #[serde(rename = "columnDefs", default)]
    pub column_defs: Vec<(String, String)>,

    // 其他字段保持不变
    // ...
}
```

### 3. 增强模板生成逻辑

```rust
fn browser_script_with_modes(scene_id: &str, scene_info: &SceneInfoEnhancedJson) -> String {
    if scene_info.modes.is_empty() {
        // 向后兼容：使用现有单模式模板
        return browser_script_with_business_logic(scene_id, scene_info);
    }

    // 多模式模板
    generate_multi_mode_script(scene_id, scene_info)
}

fn generate_multi_mode_script(scene_id: &str, scene_info: &SceneInfoEnhancedJson) -> String {
    let modes_json = serde_json::to_string_pretty(&scene_info.modes).unwrap();
    let default_mode = scene_info.default_mode.as_deref().unwrap_or("month");

    format!(r#"
const REPORT_NAME = '{scene_id}';
const MODES = {modes_json};
const DEFAULT_MODE = '{default_mode}';
const MODE_SWITCH_FIELD = '{mode_switch_field}';

function detectMode(args) {{
  const modeValue = args[MODE_SWITCH_FIELD] || DEFAULT_MODE;
  return MODES.find(m => m.condition.value === modeValue) || MODES[0];
}}

function buildModeRequest(args, mode) {{
  const endpoint = mode.api_endpoint;
  const template = mode.request_template || {{}};

  // 根据 contentType 构建请求
  const contentType = endpoint.content_type || 'application/json';
  const url = endpoint.url;
  const method = endpoint.method || 'POST';

  // 解析模板，替换变量
  let body;
  if (contentType === 'application/x-www-form-urlencoded') {{
    body = {{ ...template }};
    // 替换模板变量
    for (const [key, value] of Object.entries(body)) {{
      if (typeof value === 'string' && value.startsWith('${{') && value.endsWith('}}')) {{
        const expr = value.slice(2, -1);
        body[key] = eval(expr);
      }}
    }}
    body.orgno = args.org_code;
  }} else {{
    body = JSON.stringify({{ ...template, ...args }});
  }}

  return {{ url, method, headers: {{ 'Content-Type': contentType }}, body }};
}}

function normalizeModeRows(data, mode) {{
  const rules = mode.normalize_rules || {{ type: 'validate_all_columns' }};
  const columns = mode.column_defs.map(([key]) => key);

  if (!Array.isArray(data)) return [];

  return data.map(row => {{
    const result = {{}};
    for (const key of columns) {{
      result[key] = row[key] ?? '';
    }}
    return result;
  }}).filter(row => {{
    if (!rules.filter_null) return true;

    if (rules.type === 'validate_required') {{
      return rules.required_fields.every(f => row[f] !== '');
    }}
    return columns.every(k => row[k] !== '');
  }});
}}

async function queryModeData(args, mode) {{
  const endpoint = mode.api_endpoint;
  const request = buildModeRequest(args, mode);

  // jQuery 优先
  if (typeof $ !== 'undefined' && typeof $.ajax === 'function') {{
    const contentType = endpoint.content_type || 'application/json';
    return new Promise((resolve, reject) => {{
      $.ajax({{
        url: request.url,
        type: request.method,
        data: request.body,
        contentType: contentType,
        dataType: 'json',
        success: resolve,
        error: (xhr, status, err) => reject(new Error(
          `API failed (${{xhr.status}}): ${{err}}`
        ))
      }});
    }});
  }}

  // fetch fallback
  if (typeof fetch === 'function') {{
    const response = await fetch(request.url, {{
      method: request.method,
      headers: request.headers,
      body: request.method !== 'GET' ? request.body : undefined
    }});
    if (!response.ok) {{
      const text = await response.text().catch(() => '');
      throw new Error(`HTTP ${{response.status}}: ${{text.substring(0, 200)}}`);
    }}
    return response.json();
  }}

  throw new Error('No HTTP client available');
}}

async function buildBrowserEntrypointResult(args, deps = defaultDeps) {{
  // 1. 参数验证
  const validation = validateArgs(args);
  if (!validation.valid) {{
    return buildArtifact({{
      status: 'blocked',
      blockedReason: 'validation_failed',
      reasons: validation.errors,
      rows: [],
      args
    }});
  }}

  // 2. 页面上下文验证
  const pageValidation = typeof deps.validatePageContext === 'function'
    ? deps.validatePageContext(args)
    : {{ ok: true }};
  if (!pageValidation?.ok) {{
    return buildArtifact({{
      status: 'blocked',
      blockedReason: pageValidation?.reason || 'page_context_mismatch',
      reasons: [pageValidation?.reason || 'page_context_mismatch'],
      rows: [],
      args
    }});
  }}

  // 3. 检测模式
  const mode = detectMode(args);

  // 4. 数据获取
  const reasons = [];
  let rawData = null;
  try {{
    rawData = await queryModeData(args, mode);
  }} catch (error) {{
    return buildArtifact({{
      status: 'error',
      fatalError: error.message,
      reasons: ['api_query_failed:' + error.message],
      rows: [],
      args
    }});
  }}

  // 5. 提取响应数据
  const responsePath = mode.response_path || '';
  let data = rawData;
  if (responsePath && rawData) {{
    data = rawData[responsePath] || rawData;
  }}

  // 6. 数据归一化
  const rows = normalizeModeRows(data, mode);
  if (rows.length === 0 && Array.isArray(data) && data.length > 0) {{
    reasons.push('row_normalization_partial');
  }}

  // 7. 构建 Artifact
  return buildArtifact({{
    reasons,
    rows,
    args,
    columnDefs: mode.column_defs,
    columns: mode.column_defs.map(([key]) => key)
  }});
}}
"#, scene_id = scene_id, modes_json = modes_json, default_mode = default_mode, mode_switch_field = scene_info.mode_switch_field.as_deref().unwrap_or("period_mode"))
}
```

## Testing Strategy

### 单元测试

1. **Schema 解析测试**
   - 测试多模式 JSON 正确解析
   - 测试向后兼容（无 modes 字段时退化）

2. **模式检测测试**
   - 测试 `detectMode()` 根据参数正确选择模式

3. **请求构建测试**
   - 测试 form-urlencoded 格式正确
   - 测试 JSON 格式正确
   - 测试模板变量替换

4. **数据归一化测试**
   - 测试 validate_required 类型
   - 测试 validate_all_columns 类型
   - 测试空行过滤

### 集成测试

1. **端到端测试**
   - 使用 tq-lineloss-report 源场景
   - 验证生成的脚本与手写版本功能一致

2. **回归测试**
   - 验证单模式场景仍正常工作
   - 验证现有测试用例通过

## Migration Path

### Phase 1: Schema 增强

1. 增强 Rust schema 结构
2. 更新 LLM 提取 prompt
3. 验证 schema 解析正确

### Phase 2: 模板实现

1. 实现多模式模板
2. 实现请求格式检测
3. 实现数据归一化规则

### Phase 3: 测试验证

1. 使用 tq-lineloss-report 源场景测试
2. 对比生成代码与手写代码
3. 修复差异

## Risks and Mitigations

| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| LLM 提取不准确 | 生成代码不可用 | 提供 few-shot 示例，增加验证步骤 |
| 模式条件复杂 | 无法正确切换 | Phase 1 只支持 equals 操作符 |
| 请求模板变量 | 表达能力有限 | 支持常用表达式，复杂场景用 lessons 补充 |
| 向后兼容 | 现有场景受影响 | 无 modes 时使用旧模板 |

## Success Criteria

1. **多模式支持**：能够生成 month/week 双模式脚本
2. **请求格式正确**：自动检测 form-urlencoded vs JSON
3. **数据验证**：支持关键字段验证和空行过滤
4. **向后兼容**：单模式场景不受影响
5. **代码质量**：生成的代码与手写 tq-lineloss-report 功能对等
