feat: add intent-driven script generation components [AC-IDS-04]
- Add FlowCache for Redis-based flow instance caching - Add ScriptGenerator for flexible mode script generation - Add TemplateEngine for template variable filling - Add VariableExtractor for context variable extraction
This commit is contained in:
parent
2972c5174e
commit
fcc8869fea
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
Cache services for AI Service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.services.cache.flow_cache import FlowCache, get_flow_cache
|
||||||
|
|
||||||
|
__all__ = ["FlowCache", "get_flow_cache"]
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""
|
||||||
|
Script Generator for Intent-Driven Script Flow.
|
||||||
|
[AC-IDS-04] Flexible mode script generation with LLM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptGenerator:
|
||||||
|
"""
|
||||||
|
[AC-IDS-04] Flexible mode script generator.
|
||||||
|
Generates dynamic scripts based on intent, constraints, and conversation history.
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 5.0
|
||||||
|
MAX_SCRIPT_LENGTH = 200
|
||||||
|
|
||||||
|
def __init__(self, llm_client: Any = None):
|
||||||
|
"""
|
||||||
|
Initialize ScriptGenerator.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_client: LLM client for text generation (optional, for testing)
|
||||||
|
"""
|
||||||
|
self._llm_client = llm_client
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
intent: str,
|
||||||
|
intent_description: str | None,
|
||||||
|
constraints: list[str] | None,
|
||||||
|
context: dict[str, Any] | None,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
fallback: str,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
[AC-IDS-04] Generate flexible script based on intent and context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
intent: Step intent (e.g., "获取用户姓名")
|
||||||
|
intent_description: Detailed intent description
|
||||||
|
constraints: Script constraints (e.g., ["必须礼貌", "语气自然"])
|
||||||
|
context: Session context with collected inputs
|
||||||
|
history: Conversation history (last N turns)
|
||||||
|
fallback: Fallback script when generation fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated script text or fallback
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prompt = self._build_prompt(
|
||||||
|
intent=intent,
|
||||||
|
intent_description=intent_description,
|
||||||
|
constraints=constraints,
|
||||||
|
context=context,
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._llm_client:
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
self._llm_client.generate(messages),
|
||||||
|
timeout=self.DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
generated = response.content.strip() if hasattr(response, 'content') else str(response).strip()
|
||||||
|
|
||||||
|
if len(generated) > self.MAX_SCRIPT_LENGTH * 2:
|
||||||
|
generated = generated[:self.MAX_SCRIPT_LENGTH * 2]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-IDS-04] Generated flexible script: "
|
||||||
|
f"intent={intent}, length={len(generated)}"
|
||||||
|
)
|
||||||
|
return generated
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"[AC-IDS-05] No LLM client configured, using fallback"
|
||||||
|
)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"[AC-IDS-05] Script generation timeout, use fallback: "
|
||||||
|
f"intent={intent}"
|
||||||
|
)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[AC-IDS-05] Script generation failed: {e}, use fallback"
|
||||||
|
)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def _build_prompt(
|
||||||
|
self,
|
||||||
|
intent: str,
|
||||||
|
intent_description: str | None,
|
||||||
|
constraints: list[str] | None,
|
||||||
|
context: dict[str, Any] | None,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
[AC-IDS-04] Build LLM prompt for script generation.
|
||||||
|
"""
|
||||||
|
prompt_parts = [
|
||||||
|
"你是一个客服对话系统,当前需要执行以下步骤:",
|
||||||
|
"",
|
||||||
|
f"【步骤目标】{intent}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if intent_description:
|
||||||
|
prompt_parts.append(f"【详细说明】{intent_description}")
|
||||||
|
|
||||||
|
if constraints:
|
||||||
|
prompt_parts.append("")
|
||||||
|
prompt_parts.append("【约束条件】")
|
||||||
|
for c in constraints:
|
||||||
|
prompt_parts.append(f"- {c}")
|
||||||
|
|
||||||
|
if history:
|
||||||
|
prompt_parts.append("")
|
||||||
|
prompt_parts.append("【对话历史】")
|
||||||
|
for msg in history[-3:]:
|
||||||
|
role = "用户" if msg.get("role") == "user" else "客服"
|
||||||
|
content = msg.get("content", "")
|
||||||
|
prompt_parts.append(f"{role}: {content}")
|
||||||
|
|
||||||
|
if context and context.get("inputs"):
|
||||||
|
prompt_parts.append("")
|
||||||
|
prompt_parts.append("【已收集信息】")
|
||||||
|
for inp in context["inputs"]:
|
||||||
|
if isinstance(inp, dict):
|
||||||
|
step = inp.get("step", "?")
|
||||||
|
input_text = inp.get("input", "")
|
||||||
|
prompt_parts.append(f"- 步骤{step}: {input_text}")
|
||||||
|
else:
|
||||||
|
prompt_parts.append(f"- {inp}")
|
||||||
|
|
||||||
|
prompt_parts.extend([
|
||||||
|
"",
|
||||||
|
f"请生成一句符合目标和约束的话术(不超过{self.MAX_SCRIPT_LENGTH}字)。",
|
||||||
|
"只返回话术内容,不要解释。",
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(prompt_parts)
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""
|
||||||
|
Template Engine for Intent-Driven Script Flow.
|
||||||
|
[AC-IDS-06] Template mode script generation with variable filling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateEngine:
|
||||||
|
"""
|
||||||
|
[AC-IDS-06] Template script engine.
|
||||||
|
Fills template variables using context or LLM generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
VARIABLE_PATTERN = re.compile(r'\{(\w+)\}')
|
||||||
|
DEFAULT_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
def __init__(self, llm_client: Any = None):
|
||||||
|
"""
|
||||||
|
Initialize TemplateEngine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_client: LLM client for variable generation (optional)
|
||||||
|
"""
|
||||||
|
self._llm_client = llm_client
|
||||||
|
|
||||||
|
async def fill_template(
|
||||||
|
self,
|
||||||
|
template: str,
|
||||||
|
context: dict[str, Any] | None,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
[AC-IDS-06] Fill template variables with context or LLM-generated values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Script template with {variable} placeholders
|
||||||
|
context: Session context with collected inputs
|
||||||
|
history: Conversation history for context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filled template string
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
variables = self.VARIABLE_PATTERN.findall(template)
|
||||||
|
|
||||||
|
if not variables:
|
||||||
|
return template
|
||||||
|
|
||||||
|
variable_values = {}
|
||||||
|
for var in variables:
|
||||||
|
value = await self._generate_variable_value(
|
||||||
|
variable_name=var,
|
||||||
|
context=context,
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
variable_values[var] = value
|
||||||
|
|
||||||
|
result = template
|
||||||
|
for var, value in variable_values.items():
|
||||||
|
result = result.replace(f"{{{var}}}", value)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[AC-IDS-06] Filled template: "
|
||||||
|
f"variables={list(variable_values.keys())}"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[AC-IDS-06] Template fill failed: {e}, return original")
|
||||||
|
return template
|
||||||
|
|
||||||
|
async def _generate_variable_value(
|
||||||
|
self,
|
||||||
|
variable_name: str,
|
||||||
|
context: dict[str, Any] | None,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Generate value for a single template variable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
variable_name: Variable name to generate value for
|
||||||
|
context: Session context
|
||||||
|
history: Conversation history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated variable value
|
||||||
|
"""
|
||||||
|
if context and variable_name in context:
|
||||||
|
return str(context[variable_name])
|
||||||
|
|
||||||
|
if context and context.get("inputs"):
|
||||||
|
for inp in context["inputs"]:
|
||||||
|
if isinstance(inp, dict):
|
||||||
|
if inp.get("variable") == variable_name:
|
||||||
|
return str(inp.get("input", f"[{variable_name}]"))
|
||||||
|
|
||||||
|
if self._llm_client:
|
||||||
|
prompt = self._build_variable_prompt(
|
||||||
|
variable_name=variable_name,
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
self._llm_client.generate(messages),
|
||||||
|
timeout=self.DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
value = response.content.strip() if hasattr(response, 'content') else str(response).strip()
|
||||||
|
return value
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"[AC-IDS-06] Variable generation timeout for {variable_name}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[AC-IDS-06] Variable generation failed for {variable_name}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[AC-IDS-06] Failed to generate value for {variable_name}, "
|
||||||
|
f"use placeholder"
|
||||||
|
)
|
||||||
|
return f"[{variable_name}]"
|
||||||
|
|
||||||
|
def _build_variable_prompt(
|
||||||
|
self,
|
||||||
|
variable_name: str,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Build prompt for variable value generation.
|
||||||
|
"""
|
||||||
|
prompt_parts = [
|
||||||
|
f'根据对话历史,为变量 "{variable_name}" 生成合适的值。',
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if history:
|
||||||
|
prompt_parts.append("对话历史:")
|
||||||
|
for msg in history[-3:]:
|
||||||
|
role = "用户" if msg.get("role") == "user" else "客服"
|
||||||
|
content = msg.get("content", "")
|
||||||
|
prompt_parts.append(f"{role}: {content}")
|
||||||
|
prompt_parts.append("")
|
||||||
|
|
||||||
|
prompt_parts.extend([
|
||||||
|
"只返回变量值,不要解释。",
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(prompt_parts)
|
||||||
|
|
||||||
|
def extract_variables(self, template: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Extract variable names from template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template: Template string with {variable} placeholders
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of variable names
|
||||||
|
"""
|
||||||
|
return self.VARIABLE_PATTERN.findall(template)
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
"""
|
||||||
|
Variable Extractor for Intent-Driven Script Flow.
|
||||||
|
从用户输入中提取期望变量,如年级、学科等。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
VARIABLE_PATTERNS = {
|
||||||
|
"grade": [
|
||||||
|
(r"初[一二三]", lambda m: m.group(0)),
|
||||||
|
(r"高[一二三]", lambda m: m.group(0)),
|
||||||
|
(r"七年级", lambda m: "初一"),
|
||||||
|
(r"八年级", lambda m: "初二"),
|
||||||
|
(r"九年级", lambda m: "初三"),
|
||||||
|
(r"高一", lambda m: "高一"),
|
||||||
|
(r"高二", lambda m: "高二"),
|
||||||
|
(r"高三", lambda m: "高三"),
|
||||||
|
],
|
||||||
|
"subject": [
|
||||||
|
(r"语文", lambda m: "语文"),
|
||||||
|
(r"数学", lambda m: "数学"),
|
||||||
|
(r"英语|英文", lambda m: "英语"),
|
||||||
|
(r"物理", lambda m: "物理"),
|
||||||
|
(r"化学", lambda m: "化学"),
|
||||||
|
(r"生物", lambda m: "生物"),
|
||||||
|
(r"历史", lambda m: "历史"),
|
||||||
|
(r"地理", lambda m: "地理"),
|
||||||
|
(r"政治", lambda m: "政治"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VariableExtractor:
|
||||||
|
"""
|
||||||
|
变量提取器
|
||||||
|
从用户输入中提取期望变量(如年级、学科等)
|
||||||
|
|
||||||
|
支持两种模式:
|
||||||
|
1. 规则匹配: 使用预定义的正则表达式匹配
|
||||||
|
2. LLM 提取: 使用大语言模型智能提取
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 5.0
|
||||||
|
|
||||||
|
def __init__(self, llm_client: Any = None):
|
||||||
|
"""
|
||||||
|
Initialize VariableExtractor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
llm_client: LLM client for intelligent extraction (optional)
|
||||||
|
"""
|
||||||
|
self._llm_client = llm_client
|
||||||
|
|
||||||
|
async def extract(
|
||||||
|
self,
|
||||||
|
user_input: str,
|
||||||
|
expected_variables: list[str],
|
||||||
|
history: list[dict[str, str]] | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
从用户输入中提取期望变量
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_input: 用户输入文本
|
||||||
|
expected_variables: 期望提取的变量列表,如 ["grade", "subject"]
|
||||||
|
history: 对话历史(用于 LLM 提取时的上下文)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
提取的变量字典,如 {"grade": "初一", "subject": "语文"}
|
||||||
|
"""
|
||||||
|
if not expected_variables:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
for var_name in expected_variables:
|
||||||
|
value = await self._extract_variable(
|
||||||
|
variable_name=var_name,
|
||||||
|
user_input=user_input,
|
||||||
|
history=history,
|
||||||
|
)
|
||||||
|
if value:
|
||||||
|
result[var_name] = value
|
||||||
|
|
||||||
|
if result:
|
||||||
|
logger.info(f"[VariableExtractor] Extracted variables: {result}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _extract_variable(
|
||||||
|
self,
|
||||||
|
variable_name: str,
|
||||||
|
user_input: str,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
提取单个变量
|
||||||
|
|
||||||
|
先尝试规则匹配,失败则使用 LLM 提取
|
||||||
|
"""
|
||||||
|
value = self._extract_by_pattern(variable_name, user_input)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if self._llm_client:
|
||||||
|
value = await self._extract_by_llm(variable_name, user_input, history)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_by_pattern(self, variable_name: str, user_input: str) -> str | None:
|
||||||
|
"""
|
||||||
|
使用正则表达式提取变量
|
||||||
|
"""
|
||||||
|
patterns = VARIABLE_PATTERNS.get(variable_name, [])
|
||||||
|
for pattern, extractor in patterns:
|
||||||
|
match = re.search(pattern, user_input)
|
||||||
|
if match:
|
||||||
|
return extractor(match)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _extract_by_llm(
|
||||||
|
self,
|
||||||
|
variable_name: str,
|
||||||
|
user_input: str,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
使用 LLM 提取变量
|
||||||
|
"""
|
||||||
|
prompt = self._build_extraction_prompt(variable_name, user_input, history)
|
||||||
|
|
||||||
|
try:
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
self._llm_client.generate(messages),
|
||||||
|
timeout=self.DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
value = response.content.strip() if hasattr(response, 'content') else str(response).strip()
|
||||||
|
|
||||||
|
if value and value not in ["未知", "无法确定", "无", "None", "null"]:
|
||||||
|
return value
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(
|
||||||
|
f"[VariableExtractor] LLM extraction timeout for {variable_name}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[VariableExtractor] LLM extraction failed for {variable_name}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_extraction_prompt(
|
||||||
|
self,
|
||||||
|
variable_name: str,
|
||||||
|
user_input: str,
|
||||||
|
history: list[dict[str, str]] | None,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
构建变量提取的提示词
|
||||||
|
"""
|
||||||
|
variable_descriptions = {
|
||||||
|
"grade": "年级(如:初一、初二、初三、高一、高二、高三)",
|
||||||
|
"subject": "学科(如:语文、数学、英语、物理、化学、生物)",
|
||||||
|
"type": "内容类型(如:痛点、学科特点、能力要求、课程价值、观点)",
|
||||||
|
}
|
||||||
|
|
||||||
|
description = variable_descriptions.get(variable_name, variable_name)
|
||||||
|
|
||||||
|
prompt_parts = [
|
||||||
|
f'请从以下用户输入中提取"{description}"信息。',
|
||||||
|
"",
|
||||||
|
f"用户输入:{user_input}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
if history:
|
||||||
|
prompt_parts.append("对话历史:")
|
||||||
|
for msg in history[-3:]:
|
||||||
|
role = "用户" if msg.get("role") == "user" else "客服"
|
||||||
|
content = msg.get("content", "")
|
||||||
|
prompt_parts.append(f"{role}: {content}")
|
||||||
|
prompt_parts.append("")
|
||||||
|
|
||||||
|
prompt_parts.extend([
|
||||||
|
"要求:",
|
||||||
|
"1. 如果能确定,直接返回提取的值(如:初一、语文)",
|
||||||
|
"2. 如果无法确定,返回\"未知\"",
|
||||||
|
"3. 只返回提取的值,不要解释",
|
||||||
|
])
|
||||||
|
|
||||||
|
return "\n".join(prompt_parts)
|
||||||
Loading…
Reference in New Issue