@roll-agent/octopus-agent 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -125
- package/SKILL.md +27 -102
- package/package.json +12 -18
- package/references/env.yaml +1 -22
- package/scripts/start-octopus-agent.js +1 -125
- package/src/config.js +13 -0
- package/src/context.js +5 -0
- package/src/mcp-client.js +80 -0
- package/src/orchestrator.js +57 -0
- package/src/server-core.js +67 -0
- package/src/server.js +10 -0
- package/src/stdio.js +29 -0
- package/octopus_skill/__init__.py +0 -11
- package/pyproject.toml +0 -19
- package/scripts/refresh_dictionary.py +0 -67
- package/scripts/verify_binding.py +0 -47
- package/src/octopus_skill/__init__.py +0 -5
- package/src/octopus_skill/__main__.py +0 -4
- package/src/octopus_skill/_version.py +0 -3
- package/src/octopus_skill/agent.py +0 -4971
- package/src/octopus_skill/audit_logger.py +0 -29
- package/src/octopus_skill/config.py +0 -129
- package/src/octopus_skill/context.py +0 -17
- package/src/octopus_skill/device_info.py +0 -7
- package/src/octopus_skill/exporter.py +0 -66
- package/src/octopus_skill/llm_result_renderer.py +0 -101
- package/src/octopus_skill/llm_sql_generator.py +0 -197
- package/src/octopus_skill/main.py +0 -26
- package/src/octopus_skill/mcp_client.py +0 -114
- package/src/octopus_skill/octopus_run.py +0 -752
- package/src/octopus_skill/prompt_builder.py +0 -519
- package/src/octopus_skill/question_analyzer.py +0 -399
- package/src/octopus_skill/result_renderer.py +0 -367
- package/src/octopus_skill/schema_cache.py +0 -49
- package/src/octopus_skill/schema_context_retriever.py +0 -1053
- package/src/octopus_skill/sql_generator.py +0 -2788
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
"""LLM-based question analysis: intent classification, query target extraction, and question normalization."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import re
|
|
7
|
-
from dataclasses import dataclass, field
|
|
8
|
-
from datetime import date, timedelta
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
from .llm_sql_generator import Sampler
|
|
12
|
-
from .prompt_builder import (
|
|
13
|
-
filter_agent_instructions_by_stage,
|
|
14
|
-
format_numbered_instruction_lines,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
QUESTION_ANALYSIS_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的需求分析专家。
|
|
19
|
-
你的职责是深度理解用户的自然语言问题,输出结构化的需求分析结果,供后续 SQL 生成模块使用。
|
|
20
|
-
业务规则的唯一来源是 payload 中的 mandatoryAgentInstructions;必须逐条遵守,不得使用训练记忆中的业务口径替代 schema 要求。
|
|
21
|
-
|
|
22
|
-
## 你的能力
|
|
23
|
-
|
|
24
|
-
1. **意图分类**:判断用户的真实意图属于哪种类型。
|
|
25
|
-
2. **查询目标提取**:从问题中识别用户想查的业务对象、指标、筛选条件和排序/分组需求。
|
|
26
|
-
3. **问题规范化**:把口语化、模糊的问题改写为结构清晰、无歧义的查询描述。
|
|
27
|
-
4. **噪声值识别**:识别不应作为实体名称筛选值的词,并说明它应归属的语义角色。
|
|
28
|
-
|
|
29
|
-
## 意图分类规则
|
|
30
|
-
|
|
31
|
-
- mutation:只有当用户明确表明想新增、修改、删除、发布、提交数据等写操作时才返回。
|
|
32
|
-
- query:只有当用户明确提出业务数据查询、统计、查看数据、分析报告等需求时才返回。
|
|
33
|
-
- non_query:用户只是测试/检查/使用丸子Agent、询问工具或系统能力、确认是否可用、或单独回复“确认”但没有业务数据查询时返回。
|
|
34
|
-
- 不要输出 clarification_needed 或 unsupported;缺少信息或暂不支持的业务查询也先归为 query,后续 schema 匹配、查询计划确认或业务校验会继续处理。
|
|
35
|
-
|
|
36
|
-
## 查询目标提取规则
|
|
37
|
-
|
|
38
|
-
从问题中提取以下维度(没有的留空数组或 null):
|
|
39
|
-
- targetEntities:用户想查的业务对象(如"岗位""门店""品牌""员工")
|
|
40
|
-
- metrics:用户想看的指标(如"数量""总数""平均薪资")
|
|
41
|
-
- filters:用户提到的筛选条件(如"已发布的""北京的""最近7天")
|
|
42
|
-
- groupBy:分组维度(如"按品牌""按城市")
|
|
43
|
-
- orderBy:排序要求(如"按数量从多到少")
|
|
44
|
-
- timeRange:时间范围(如"最近一周""2024年1月");必须保留原始时间词,并按 payload.currentDate 计算明确起止日期
|
|
45
|
-
- limit:用户要求的返回条数(如"前10条""全部")
|
|
46
|
-
- entityCandidates:从问题中抽取的实体候选;只有真实名称才输出为 isNoise=false
|
|
47
|
-
- noiseValues:被误当实体值会造成错误筛选的词;注意噪声值不代表丢弃,它可能仍然属于 timeRange、filters、metrics 或 intent
|
|
48
|
-
- requiredConcepts:用户问题必须依赖的 schema 概念;只有当业务口径必须由 schema.concepts/metrics/examples 明确定义时才输出
|
|
49
|
-
- mutationSignals:用户明确表达写操作时的信号;如果只是查询新增/修改/下架记录,不算写操作
|
|
50
|
-
- planAction:用户在确认查询计划时的动作,普通业务查询填 null
|
|
51
|
-
|
|
52
|
-
## 噪声值规则
|
|
53
|
-
|
|
54
|
-
- 表达时间的词不要作为实体名称;应放入 timeRange。
|
|
55
|
-
- timeRange.originalText 必须保留用户原文中的时间词,例如“近一年”“最近7天”“2024年1月”;normalizedQuestion 也必须保留该时间词,不要改写成某个月。
|
|
56
|
-
- 相对时间必须基于 payload.currentDate 计算 start 和 endExclusive,endExclusive 使用左闭右开区间的次日或下一周期首日。
|
|
57
|
-
- 例如 payload.currentDate=2026-07-07 时,“近一年”必须输出 start=2025-07-07、endExclusive=2026-07-08;严禁分析成 2025年7月、2025-07-01 到 2025-08-01。
|
|
58
|
-
- “所有”“全部”“全量”“完整”只表示返回范围或 limit 诉求;不得覆盖、删除或扩大用户已经明确提出的 timeRange。
|
|
59
|
-
- 表达查询动作、统计意图、范围、排序或状态口径的词不要作为实体名称;应放入 filters、metrics、orderBy 或其他对应字段。
|
|
60
|
-
- 表达岗位生命周期、操作动作或状态变化的词不要作为岗位名称;例如“新上架”“上架”“下架”“发布”“新发布”“新增发布”应放入 filters 或 noiseValues.role=action/status,不要作为 job_name/name/title 的 LIKE 值。
|
|
61
|
-
- 只有用户明确说“名称包含/岗位名称是/叫做/简称为”时,才可以把动作或状态词作为名称筛选值。
|
|
62
|
-
- 遇到“名称 + 时间词 + 阶段/动作词 + 业务对象/指标”的连续短语时,必须拆分语义,不要把整段短语当作实体名称。
|
|
63
|
-
- 例如“成都你六姐明天面试的工单数量”:`成都你六姐` 是品牌/项目候选,`明天` 是 timeRange,`面试` 是业务阶段或动作,`工单数量` 是业务对象和指标;`成都你六姐明天面试` 必须放入 noiseValues,不能作为任何时间字段、名称字段或工单字段的筛选值。
|
|
64
|
-
- 遇到“名称 + 有多少 + 时间词 + 阶段/动作词 + 业务对象”时,查询对象必须是最后的业务对象,指标必须是该业务对象数量;例如“成都你六姐有多少明天面试的工单”必须输出 targetEntities=["工单"]、metrics=["工单数量"],不能输出 targetEntities=["品牌"] 或 targetEntities=["项目"]。
|
|
65
|
-
- 当用户未明确说明“品牌”或“项目”,但名称片段像品牌/项目名称时,entityCandidates 同时输出 brand 和 sponge_project 两个候选,后续模块会走实体检索确认。
|
|
66
|
-
- 只有能作为品牌、项目、门店、城市、岗位等真实名称的词,才可作为 entityCandidates 的非噪声实体值。
|
|
67
|
-
- 不要因为一个词是状态或时间就忽略它;必须保留到正确语义字段里。
|
|
68
|
-
|
|
69
|
-
## 必需 schema 概念规则
|
|
70
|
-
|
|
71
|
-
- 遇到“今天招满”“今日招满”“昨天招满”“某天招满”“近7天招满”“本月招满”“有哪些岗位招满”等时间范围内岗位招满问题时,必须输出 requiredConcepts=[{"name":"filled_job_by_period","label":"按时间段招满岗位"}];今天、昨天、某天等只作为 timeRange,不要使用单日专用概念。
|
|
72
|
-
- 如果 schema 缺少 requiredConcepts 中的概念,后续模块会返回 schema 口径缺失;你不要把招满解释成 job_store.update_at、岗位状态或名称筛选。
|
|
73
|
-
|
|
74
|
-
## 问题规范化规则
|
|
75
|
-
|
|
76
|
-
- 保留用户的核心业务语义
|
|
77
|
-
- 去除口语化的填充词(嗯、那个、帮我看下)
|
|
78
|
-
- 补全省略的主语或宾语
|
|
79
|
-
- 不改变用户的原始筛选意图
|
|
80
|
-
- 如果问题已经足够清晰,原样返回
|
|
81
|
-
|
|
82
|
-
## 输出格式
|
|
83
|
-
|
|
84
|
-
只输出一个 JSON 对象,不输出任何解释文本:
|
|
85
|
-
{
|
|
86
|
-
"intent": "query|mutation|non_query",
|
|
87
|
-
"confidence": 0.0~1.0,
|
|
88
|
-
"targetEntities": ["业务对象1", "业务对象2"],
|
|
89
|
-
"metrics": ["指标1"],
|
|
90
|
-
"filters": [{"field": "字段含义", "operator": "=|LIKE|>|<|IN|BETWEEN", "value": "值"}],
|
|
91
|
-
"groupBy": ["维度1"],
|
|
92
|
-
"orderBy": [{"field": "字段", "direction": "ASC|DESC"}],
|
|
93
|
-
"timeRange": {"type": "relative|absolute", "value": "描述", "originalText": "原始时间词", "start": "YYYY-MM-DD", "endExclusive": "YYYY-MM-DD"},
|
|
94
|
-
"entityCandidates": [{"type": "实体类型", "value": "实体原文", "isNoise": false, "role": "entity"}],
|
|
95
|
-
"noiseValues": [{"value": "词语原文", "role": "time|intent|metric|scope|status|action|other", "reason": "为什么不能作为实体名称"}],
|
|
96
|
-
"requiredConcepts": [{"name": "schema概念名", "label": "中文口径名", "fallbackNames": ["可替代schema概念名"]}],
|
|
97
|
-
"mutationSignals": [{"value": "词语原文", "reason": "为什么表示写操作"}],
|
|
98
|
-
"planAction": "confirm|revise|null",
|
|
99
|
-
"limit": null,
|
|
100
|
-
"normalizedQuestion": "规范化后的问题",
|
|
101
|
-
"clarificationReason": null
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
当 intent 为 mutation 时,仍然要尽量提取已有信息。
|
|
105
|
-
"""
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
@dataclass(frozen=True)
|
|
109
|
-
class QuestionAnalysis:
|
|
110
|
-
"""Structured result of LLM-based question analysis."""
|
|
111
|
-
|
|
112
|
-
intent: str # query | mutation | non_query
|
|
113
|
-
confidence: float
|
|
114
|
-
target_entities: list[str] = field(default_factory=list)
|
|
115
|
-
metrics: list[str] = field(default_factory=list)
|
|
116
|
-
filters: list[dict[str, str]] = field(default_factory=list)
|
|
117
|
-
group_by: list[str] = field(default_factory=list)
|
|
118
|
-
order_by: list[dict[str, str]] = field(default_factory=list)
|
|
119
|
-
time_range: dict[str, str] | None = None
|
|
120
|
-
entity_candidates: list[dict[str, Any]] = field(default_factory=list)
|
|
121
|
-
noise_values: list[dict[str, str]] = field(default_factory=list)
|
|
122
|
-
required_concepts: list[dict[str, Any]] = field(default_factory=list)
|
|
123
|
-
mutation_signals: list[dict[str, str]] = field(default_factory=list)
|
|
124
|
-
plan_action: str | None = None
|
|
125
|
-
limit: int | None = None
|
|
126
|
-
normalized_question: str = ""
|
|
127
|
-
clarification_reason: str | None = None
|
|
128
|
-
raw: dict[str, Any] = field(default_factory=dict)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
class QuestionAnalysisError(Exception):
|
|
132
|
-
"""Raised when question analysis fails."""
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
SAMPLING_MAX_RETRIES = 2
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def analyze_question(
|
|
139
|
-
question: str,
|
|
140
|
-
sampler: Sampler,
|
|
141
|
-
agent_instructions: list[dict[str, Any]] | None = None,
|
|
142
|
-
*,
|
|
143
|
-
max_sampling_retries: int = SAMPLING_MAX_RETRIES,
|
|
144
|
-
) -> QuestionAnalysis:
|
|
145
|
-
"""Use LLM to analyze user question and extract structured intent + query targets.
|
|
146
|
-
|
|
147
|
-
Args:
|
|
148
|
-
question: The user's original question text.
|
|
149
|
-
sampler: LLM sampler callback (system_prompt, user_prompt) -> response.
|
|
150
|
-
agent_instructions: Schema agentInstructions; only ``【生成前】`` entries are injected.
|
|
151
|
-
|
|
152
|
-
Returns:
|
|
153
|
-
QuestionAnalysis dataclass with structured analysis result.
|
|
154
|
-
|
|
155
|
-
Raises:
|
|
156
|
-
QuestionAnalysisError: If the LLM call fails or returns unparseable output.
|
|
157
|
-
"""
|
|
158
|
-
prompt = build_question_analysis_prompt(question, agent_instructions)
|
|
159
|
-
|
|
160
|
-
raw_response: str | None = None
|
|
161
|
-
for attempt in range(max_sampling_retries + 1):
|
|
162
|
-
try:
|
|
163
|
-
raw_response = sampler(QUESTION_ANALYSIS_SYSTEM_PROMPT, prompt)
|
|
164
|
-
break
|
|
165
|
-
except Exception as exc:
|
|
166
|
-
if attempt >= max_sampling_retries:
|
|
167
|
-
raise QuestionAnalysisError(
|
|
168
|
-
f"LLM 调用失败(已重试 {max_sampling_retries} 次):{type(exc).__name__}: {exc}"
|
|
169
|
-
) from exc
|
|
170
|
-
|
|
171
|
-
payload = _parse_analysis_response(raw_response or "")
|
|
172
|
-
return _build_analysis(payload, question)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def build_question_analysis_prompt(
|
|
176
|
-
question: str,
|
|
177
|
-
agent_instructions: list[dict[str, Any]] | None = None,
|
|
178
|
-
current_date: date | None = None,
|
|
179
|
-
) -> str:
|
|
180
|
-
instruction_lines = format_numbered_instruction_lines(
|
|
181
|
-
filter_agent_instructions_by_stage(agent_instructions or [], "before")
|
|
182
|
-
)
|
|
183
|
-
today = current_date or date.today()
|
|
184
|
-
recent_year_start = _one_year_before(today)
|
|
185
|
-
recent_year_end = today + timedelta(days=1)
|
|
186
|
-
payload = {
|
|
187
|
-
"mandatoryAgentInstructions": instruction_lines,
|
|
188
|
-
"currentDate": today.isoformat(),
|
|
189
|
-
"instruction": (
|
|
190
|
-
"分析 question 并输出 JSON;意图分类、时间范围识别与问题规范化必须遵守 "
|
|
191
|
-
"mandatoryAgentInstructions 和 timeRangeRules。"
|
|
192
|
-
),
|
|
193
|
-
"question": question,
|
|
194
|
-
"timeRangeRules": {
|
|
195
|
-
"mustKeepOriginalText": "timeRange.originalText 与 normalizedQuestion 必须保留用户原始时间词,不要把相对时间改写成某个月。",
|
|
196
|
-
"relativeDateBase": "所有相对时间按 currentDate 计算。",
|
|
197
|
-
"interval": "时间范围使用左闭右开区间:[start, endExclusive)。",
|
|
198
|
-
"recentOneYearExample": {
|
|
199
|
-
"currentDate": today.isoformat(),
|
|
200
|
-
"originalText": "近一年",
|
|
201
|
-
"start": recent_year_start.isoformat(),
|
|
202
|
-
"endExclusive": recent_year_end.isoformat(),
|
|
203
|
-
"forbidden": "不得输出为某个自然月,例如 2025-07-01 到 2025-08-01。",
|
|
204
|
-
},
|
|
205
|
-
},
|
|
206
|
-
"outputSchema": {
|
|
207
|
-
"intent": "query|mutation|non_query",
|
|
208
|
-
"confidence": 0.0,
|
|
209
|
-
"targetEntities": ["业务对象1"],
|
|
210
|
-
"metrics": ["指标1"],
|
|
211
|
-
"filters": [{"field": "字段含义", "operator": "=", "value": "值"}],
|
|
212
|
-
"groupBy": ["维度1"],
|
|
213
|
-
"orderBy": [{"field": "字段", "direction": "ASC"}],
|
|
214
|
-
"timeRange": {
|
|
215
|
-
"type": "relative",
|
|
216
|
-
"value": "近一年",
|
|
217
|
-
"originalText": "近一年",
|
|
218
|
-
"start": recent_year_start.isoformat(),
|
|
219
|
-
"endExclusive": recent_year_end.isoformat(),
|
|
220
|
-
},
|
|
221
|
-
"entityCandidates": [{"type": "job", "value": "店员", "isNoise": False, "role": "entity"}],
|
|
222
|
-
"noiseValues": [
|
|
223
|
-
{"value": "今天", "role": "time", "reason": "时间条件,不是实体名称"},
|
|
224
|
-
{"value": "新上架", "role": "action", "reason": "岗位生命周期动作,不是岗位名称"},
|
|
225
|
-
{"value": "成都你六姐明天面试", "role": "other", "reason": "组合短语,必须拆成品牌/项目候选、时间和业务阶段,不是单一实体名称"},
|
|
226
|
-
],
|
|
227
|
-
"compoundEntityExample": {
|
|
228
|
-
"question": "成都你六姐有多少明天面试的工单",
|
|
229
|
-
"targetEntities": ["工单"],
|
|
230
|
-
"metrics": ["工单数量"],
|
|
231
|
-
"entityCandidates": [
|
|
232
|
-
{"type": "brand", "value": "成都你六姐", "isNoise": False, "role": "entity"},
|
|
233
|
-
{"type": "sponge_project", "value": "成都你六姐", "isNoise": False, "role": "entity"},
|
|
234
|
-
],
|
|
235
|
-
"timeRange": {"originalText": "明天"},
|
|
236
|
-
"filters": [{"field": "业务阶段", "operator": "=", "value": "面试"}],
|
|
237
|
-
"forbiddenEntityValue": "成都你六姐明天面试",
|
|
238
|
-
"forbiddenTargetEntities": ["品牌", "项目"],
|
|
239
|
-
},
|
|
240
|
-
"requiredConcepts": [
|
|
241
|
-
{"name": "filled_job_by_period", "label": "按时间段招满岗位"}
|
|
242
|
-
],
|
|
243
|
-
"mutationSignals": [],
|
|
244
|
-
"planAction": None,
|
|
245
|
-
"limit": None,
|
|
246
|
-
"normalizedQuestion": "规范化后的问题",
|
|
247
|
-
"clarificationReason": None,
|
|
248
|
-
},
|
|
249
|
-
}
|
|
250
|
-
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def _one_year_before(value: date) -> date:
|
|
254
|
-
try:
|
|
255
|
-
return value.replace(year=value.year - 1)
|
|
256
|
-
except ValueError:
|
|
257
|
-
return value.replace(year=value.year - 1, day=28)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def _parse_analysis_response(text: str) -> dict[str, Any]:
|
|
261
|
-
"""Parse LLM JSON response, tolerating markdown fences and extra text."""
|
|
262
|
-
cleaned = (text or "").strip()
|
|
263
|
-
if cleaned.startswith("```"):
|
|
264
|
-
cleaned = cleaned.strip("`")
|
|
265
|
-
if cleaned.lower().startswith("json"):
|
|
266
|
-
cleaned = cleaned[4:].strip()
|
|
267
|
-
try:
|
|
268
|
-
payload = json.loads(cleaned)
|
|
269
|
-
except json.JSONDecodeError:
|
|
270
|
-
start = cleaned.find("{")
|
|
271
|
-
end = cleaned.rfind("}")
|
|
272
|
-
if start == -1 or end == -1 or end <= start:
|
|
273
|
-
raise QuestionAnalysisError("LLM 返回内容无法解析为 JSON")
|
|
274
|
-
try:
|
|
275
|
-
payload = json.loads(cleaned[start : end + 1])
|
|
276
|
-
except json.JSONDecodeError as exc:
|
|
277
|
-
raise QuestionAnalysisError(f"LLM JSON 解析失败:{exc}") from exc
|
|
278
|
-
if not isinstance(payload, dict):
|
|
279
|
-
raise QuestionAnalysisError("LLM 返回内容不是 JSON 对象")
|
|
280
|
-
return payload
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
def _build_analysis(payload: dict[str, Any], original_question: str) -> QuestionAnalysis:
|
|
284
|
-
"""Build QuestionAnalysis from parsed LLM JSON payload."""
|
|
285
|
-
intent = str(payload.get("intent") or "query")
|
|
286
|
-
if intent not in {"query", "mutation", "non_query"}:
|
|
287
|
-
intent = "query"
|
|
288
|
-
|
|
289
|
-
confidence = payload.get("confidence")
|
|
290
|
-
if not isinstance(confidence, (int, float)) or confidence < 0 or confidence > 1:
|
|
291
|
-
confidence = 0.5
|
|
292
|
-
|
|
293
|
-
target_entities = _str_list(payload.get("targetEntities"))
|
|
294
|
-
metrics = _str_list(payload.get("metrics"))
|
|
295
|
-
filters = _dict_list(payload.get("filters"))
|
|
296
|
-
group_by = _str_list(payload.get("groupBy"))
|
|
297
|
-
order_by = _dict_list(payload.get("orderBy"))
|
|
298
|
-
entity_candidates = _entity_candidate_list(payload.get("entityCandidates"))
|
|
299
|
-
noise_values = _dict_list(payload.get("noiseValues"))
|
|
300
|
-
required_concepts = _required_concept_list(payload.get("requiredConcepts"))
|
|
301
|
-
mutation_signals = _dict_list(payload.get("mutationSignals"))
|
|
302
|
-
|
|
303
|
-
plan_action = payload.get("planAction")
|
|
304
|
-
if plan_action not in {"confirm", "revise"}:
|
|
305
|
-
plan_action = None
|
|
306
|
-
|
|
307
|
-
time_range = payload.get("timeRange")
|
|
308
|
-
if not isinstance(time_range, dict):
|
|
309
|
-
time_range = None
|
|
310
|
-
|
|
311
|
-
limit_val = payload.get("limit")
|
|
312
|
-
limit = int(limit_val) if isinstance(limit_val, (int, float)) and limit_val > 0 else None
|
|
313
|
-
|
|
314
|
-
normalized = str(payload.get("normalizedQuestion") or "").strip()
|
|
315
|
-
if not normalized:
|
|
316
|
-
normalized = original_question
|
|
317
|
-
|
|
318
|
-
clarification_reason = payload.get("clarificationReason")
|
|
319
|
-
if not isinstance(clarification_reason, str):
|
|
320
|
-
clarification_reason = None
|
|
321
|
-
|
|
322
|
-
return QuestionAnalysis(
|
|
323
|
-
intent=intent,
|
|
324
|
-
confidence=float(confidence),
|
|
325
|
-
target_entities=target_entities,
|
|
326
|
-
metrics=metrics,
|
|
327
|
-
filters=filters,
|
|
328
|
-
group_by=group_by,
|
|
329
|
-
order_by=order_by,
|
|
330
|
-
time_range=time_range,
|
|
331
|
-
entity_candidates=entity_candidates,
|
|
332
|
-
noise_values=noise_values,
|
|
333
|
-
required_concepts=required_concepts,
|
|
334
|
-
mutation_signals=mutation_signals,
|
|
335
|
-
plan_action=plan_action,
|
|
336
|
-
limit=limit,
|
|
337
|
-
normalized_question=normalized,
|
|
338
|
-
clarification_reason=clarification_reason,
|
|
339
|
-
raw=payload,
|
|
340
|
-
)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
def _str_list(value: Any) -> list[str]:
|
|
344
|
-
"""Safely convert to list of strings."""
|
|
345
|
-
if not isinstance(value, list):
|
|
346
|
-
return []
|
|
347
|
-
return [str(item) for item in value if item]
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def _dict_list(value: Any) -> list[dict[str, str]]:
|
|
351
|
-
"""Safely convert to list of dicts with string values."""
|
|
352
|
-
if not isinstance(value, list):
|
|
353
|
-
return []
|
|
354
|
-
result: list[dict[str, str]] = []
|
|
355
|
-
for item in value:
|
|
356
|
-
if isinstance(item, dict):
|
|
357
|
-
result.append({str(k): str(v) for k, v in item.items()})
|
|
358
|
-
return result
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
def _entity_candidate_list(value: Any) -> list[dict[str, Any]]:
|
|
362
|
-
"""Safely convert entity candidate payloads while preserving boolean flags."""
|
|
363
|
-
if not isinstance(value, list):
|
|
364
|
-
return []
|
|
365
|
-
result: list[dict[str, Any]] = []
|
|
366
|
-
for item in value:
|
|
367
|
-
if not isinstance(item, dict):
|
|
368
|
-
continue
|
|
369
|
-
entry: dict[str, Any] = {}
|
|
370
|
-
for key, raw_value in item.items():
|
|
371
|
-
if key == "isNoise":
|
|
372
|
-
entry[key] = bool(raw_value)
|
|
373
|
-
elif raw_value is not None:
|
|
374
|
-
entry[str(key)] = str(raw_value)
|
|
375
|
-
if entry:
|
|
376
|
-
result.append(entry)
|
|
377
|
-
return result
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def _required_concept_list(value: Any) -> list[dict[str, Any]]:
|
|
381
|
-
"""Normalize required schema concept payloads while preserving fallback names."""
|
|
382
|
-
if not isinstance(value, list):
|
|
383
|
-
return []
|
|
384
|
-
result: list[dict[str, Any]] = []
|
|
385
|
-
for item in value:
|
|
386
|
-
if not isinstance(item, dict):
|
|
387
|
-
continue
|
|
388
|
-
name = str(item.get("name") or "").strip()
|
|
389
|
-
if not name:
|
|
390
|
-
continue
|
|
391
|
-
entry: dict[str, Any] = {"name": name}
|
|
392
|
-
label = str(item.get("label") or "").strip()
|
|
393
|
-
if label:
|
|
394
|
-
entry["label"] = label
|
|
395
|
-
fallback_names = _str_list(item.get("fallbackNames"))
|
|
396
|
-
if fallback_names:
|
|
397
|
-
entry["fallbackNames"] = fallback_names
|
|
398
|
-
result.append(entry)
|
|
399
|
-
return result
|