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:
MerCry 2026-03-03 00:33:06 +08:00
parent 2972c5174e
commit fcc8869fea
4 changed files with 527 additions and 0 deletions

View File

@ -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"]

View File

@ -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)

View File

@ -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)

View File

@ -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)