Phase 4: Template system with auto-select and pre-fill
Backend: - Add templates.json with 5 template definitions (news, policy, business, fiction, social) - Add template API (/api/template/list, /api/template/auto-select, /api/template/:id/filter-rules) - Register template blueprint in Flask app Frontend: - Add template API client (frontend/src/api/template.js) - Add template selector UI in Home.vue (chip buttons + auto-select button) - Add template state management and auto-select logic Locale: - Add template keys for th/en/zh Entity filter rules in templates.json for context-aware filtering in Step 1.
This commit is contained in:
@@ -64,9 +64,11 @@ def create_app(config_class=Config):
|
||||
|
||||
# 注册蓝图
|
||||
from .api import graph_bp, simulation_bp, report_bp
|
||||
from .api.template import template_bp
|
||||
app.register_blueprint(graph_bp, url_prefix='/api/graph')
|
||||
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
|
||||
app.register_blueprint(report_bp, url_prefix='/api/report')
|
||||
app.register_blueprint(template_bp, url_prefix='/api/template')
|
||||
|
||||
# 健康检查
|
||||
@app.route('/health')
|
||||
|
||||
147
backend/app/api/template.py
Normal file
147
backend/app/api/template.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Template Auto-Selection API
|
||||
Analyze seed data and recommend the best simulation template
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from flask import Blueprint, request, jsonify
|
||||
from ..utils.llm_client import LLMClient
|
||||
from ..utils.locale import t, get_locale, get_language_instruction
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger('crowdsight.template')
|
||||
|
||||
template_bp = Blueprint('template', __name__)
|
||||
|
||||
# Load templates
|
||||
_templates_path = os.path.join(os.path.dirname(__file__), '..', 'templates.json')
|
||||
with open(_templates_path, 'r', encoding='utf-8') as f:
|
||||
_templates_data = json.load(f)
|
||||
_templates = _templates_data['templates']
|
||||
|
||||
|
||||
@template_bp.route('/list', methods=['GET'])
|
||||
def list_templates():
|
||||
"""Return all available templates"""
|
||||
locale = get_locale()
|
||||
lang_key = f'prompt_{locale}' if locale in ('th', 'en', 'zh') else 'prompt_en'
|
||||
|
||||
result = []
|
||||
for tmpl in _templates:
|
||||
result.append({
|
||||
'id': tmpl['id'],
|
||||
'icon': tmpl['icon'],
|
||||
'category': tmpl['category'],
|
||||
'prompt': tmpl.get(lang_key, tmpl['prompt_en']),
|
||||
'placeholders': tmpl['placeholders'],
|
||||
'name': t(f'templates.{tmpl["id"]}'),
|
||||
})
|
||||
|
||||
return jsonify({'success': True, 'templates': result})
|
||||
|
||||
|
||||
@template_bp.route('/auto-select', methods=['POST'])
|
||||
def auto_select_template():
|
||||
"""
|
||||
Analyze seed data and recommend the best template + pre-fill prompt.
|
||||
|
||||
Request JSON:
|
||||
{
|
||||
"text": "extracted text from uploaded documents",
|
||||
"simulation_requirement": "optional user requirement"
|
||||
}
|
||||
|
||||
Response JSON:
|
||||
{
|
||||
"success": true,
|
||||
"template_id": "news_event",
|
||||
"prompt": "pre-filled prompt with actual values",
|
||||
"confidence": 0.85,
|
||||
"reasoning": "why this template was selected"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
text = data.get('text', '')[:5000] # Limit to 5000 chars
|
||||
simulation_requirement = data.get('simulation_requirement', '')
|
||||
|
||||
if not text:
|
||||
return jsonify({'success': False, 'error': 'No text provided'}), 400
|
||||
|
||||
locale = get_locale()
|
||||
lang_instruction = get_language_instruction()
|
||||
|
||||
# Build template descriptions for the LLM
|
||||
template_desc = []
|
||||
lang_key = f'prompt_{locale}' if locale in ('th', 'en', 'zh') else 'prompt_en'
|
||||
for tmpl in _templates:
|
||||
template_desc.append(f"- {tmpl['id']}: {tmpl.get(lang_key, tmpl['prompt_en'])}")
|
||||
|
||||
templates_list = '\n'.join(template_desc)
|
||||
|
||||
system_prompt = f"""You are a smart assistant that analyzes document content and recommends the best simulation template.
|
||||
|
||||
Available templates:
|
||||
{templates_list}
|
||||
|
||||
Your task:
|
||||
1. Analyze the document content
|
||||
2. Select the MOST appropriate template
|
||||
3. Fill in the template placeholders with actual values from the document
|
||||
4. Return the result in JSON format
|
||||
|
||||
{lang_instruction}"""
|
||||
|
||||
user_prompt = f"""Document content:
|
||||
{text[:3000]}
|
||||
|
||||
{f'User requirement: {simulation_requirement}' if simulation_requirement else ''}
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"template_id": "best_template_id",
|
||||
"prompt": "the template with placeholders filled in with actual values from the document",
|
||||
"confidence": 0.0-1.0,
|
||||
"reasoning": "brief explanation of why this template was chosen"
|
||||
}}"""
|
||||
|
||||
llm = LLMClient()
|
||||
result = llm.chat_json(
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
],
|
||||
temperature=0.3
|
||||
)
|
||||
|
||||
# Validate template_id
|
||||
valid_ids = [t['id'] for t in _templates]
|
||||
if result.get('template_id') not in valid_ids:
|
||||
result['template_id'] = 'news_event' # Default fallback
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'template_id': result.get('template_id', 'news_event'),
|
||||
'prompt': result.get('prompt', ''),
|
||||
'confidence': result.get('confidence', 0.5),
|
||||
'reasoning': result.get('reasoning', ''),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Template auto-select failed: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@template_bp.route('/<template_id>/filter-rules', methods=['GET'])
|
||||
def get_filter_rules(template_id):
|
||||
"""Return entity filter rules for a template (used by Step 1)"""
|
||||
for tmpl in _templates:
|
||||
if tmpl['id'] == template_id:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'template_id': template_id,
|
||||
'filter_rules': tmpl.get('entity_filter', {})
|
||||
})
|
||||
|
||||
return jsonify({'success': False, 'error': 'Template not found'}), 404
|
||||
70
backend/app/templates.json
Normal file
70
backend/app/templates.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"id": "news_event",
|
||||
"icon": "📰",
|
||||
"category": "news",
|
||||
"prompt_th": "จำลองปฏิกิริยาของประชาชนบนโซเชียลมีเดียหลังจาก [เหตุการณ์] ในช่วง [ระยะเวลา] ชั่วโมง",
|
||||
"prompt_en": "Simulate public reactions on social media after [event] within [timeframe] hours",
|
||||
"prompt_zh": "模拟[事件]发生后公众在社交媒体上的反应,时间范围[时间]小时",
|
||||
"placeholders": ["event", "timeframe"],
|
||||
"entity_filter": {
|
||||
"exclude_types": [],
|
||||
"focus": "affected_parties, public, media, officials"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "policy_regulation",
|
||||
"icon": "📋",
|
||||
"category": "policy",
|
||||
"prompt_th": "จำลองผลกระทบของนโยบาย [ชื่อนโยบาย] ต่อกลุ่มต่างๆ ในสังคม รวมถึงการโต้ตอบบนโซเชียลมีเดีย",
|
||||
"prompt_en": "Simulate the impact of [policy name] on different social groups, including social media interactions",
|
||||
"prompt_zh": "模拟[政策名称]对不同社会群体的影响,包括社交媒体互动",
|
||||
"placeholders": ["policy_name"],
|
||||
"entity_filter": {
|
||||
"exclude_types": [],
|
||||
"focus": "citizens, experts, media, government, affected_groups"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "business_ad",
|
||||
"icon": "📢",
|
||||
"category": "business",
|
||||
"prompt_th": "จำลองการแพร่กระจายและผลตอบรับของ [แคมเปญ/สินค้า] บนโซเชียลมีเดีย รวมถึงปฏิกิริยาของกลุ่มเป้าหมาย",
|
||||
"prompt_en": "Simulate the spread and reception of [campaign/product] on social media, including target audience reactions",
|
||||
"prompt_zh": "模拟[活动/产品]在社交媒体上的传播和反馈,包括目标受众的反应",
|
||||
"placeholders": ["campaign_product"],
|
||||
"entity_filter": {
|
||||
"exclude_self": true,
|
||||
"exclude_types": ["Advertiser", "BrandOwner"],
|
||||
"focus": "target_audience, competitors, influencers, media"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fiction_story",
|
||||
"icon": "📖",
|
||||
"category": "fiction",
|
||||
"prompt_th": "จำลองเรื่องราวต่อจาก [เนื้อเรื่อง] โดยให้ตัวละครแต่ละตัวมีปฏิสัมพันธ์กันบนโซเชียลมีเดีย และทำนายตอนจบ",
|
||||
"prompt_en": "Simulate the story continuation from [plot] with characters interacting on social media, and predict the ending",
|
||||
"prompt_zh": "模拟从[情节]开始的故事延续,角色在社交媒体上互动,并预测结局",
|
||||
"placeholders": ["plot"],
|
||||
"entity_filter": {
|
||||
"exclude_types": ["Author", "Narrator"],
|
||||
"focus": "characters, story_entities"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "social_culture",
|
||||
"icon": "🌍",
|
||||
"category": "social",
|
||||
"prompt_th": "จำลองผลกระทบของ [กระแสสังคม] ต่อกลุ่มคนต่างๆ บนโซเชียลมีเดีย รวมถึงการแพร่กระจายของข้อมูล",
|
||||
"prompt_en": "Simulate the impact of [social trend] on different groups on social media, including information spread",
|
||||
"prompt_zh": "模拟[社会潮流]对不同群体在社交媒体上的影响,包括信息传播",
|
||||
"placeholders": ["social_trend"],
|
||||
"entity_filter": {
|
||||
"exclude_types": [],
|
||||
"focus": "public_figures, communities, media, influencers, general_public"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
24
frontend/src/api/template.js
Normal file
24
frontend/src/api/template.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import service from './index'
|
||||
|
||||
/**
|
||||
* Get all available templates
|
||||
*/
|
||||
export const getTemplates = () => {
|
||||
return service.get('/api/template/list')
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-select template based on seed data
|
||||
* @param {Object} data - { text, simulation_requirement? }
|
||||
*/
|
||||
export const autoSelectTemplate = (data) => {
|
||||
return service.post('/api/template/auto-select', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity filter rules for a template
|
||||
* @param {string} templateId
|
||||
*/
|
||||
export const getTemplateFilterRules = (templateId) => {
|
||||
return service.get(`/api/template/${templateId}/filter-rules`)
|
||||
}
|
||||
@@ -202,6 +202,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Selector -->
|
||||
<div class="template-section">
|
||||
<div class="template-header">
|
||||
<span class="console-label">{{ $t('templates.title') }}</span>
|
||||
<button
|
||||
v-if="files.length > 0 && !templateAutoSelected"
|
||||
class="auto-select-btn"
|
||||
@click="autoSelectTemplate"
|
||||
:disabled="templateLoading"
|
||||
>
|
||||
<span v-if="templateLoading">⏳</span>
|
||||
<span v-else>✨</span>
|
||||
{{ $t('templates.autoSelect') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="template-grid">
|
||||
<button
|
||||
v-for="tmpl in availableTemplates"
|
||||
:key="tmpl.id"
|
||||
class="template-chip"
|
||||
:class="{ active: selectedTemplate === tmpl.id }"
|
||||
@click="selectTemplate(tmpl)"
|
||||
>
|
||||
<span class="template-chip-icon">{{ tmpl.icon }}</span>
|
||||
<span class="template-chip-name">{{ tmpl.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="template-chip"
|
||||
:class="{ active: selectedTemplate === 'custom' }"
|
||||
@click="selectedTemplate = 'custom'"
|
||||
>
|
||||
<span class="template-chip-icon">✏️</span>
|
||||
<span class="template-chip-name">{{ $t('templates.custom') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="console-divider">
|
||||
<span>{{ $t('home.inputParams') }}</span>
|
||||
@@ -247,10 +284,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import HistoryDatabase from '../components/HistoryDatabase.vue'
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue'
|
||||
import { getTemplates, autoSelectTemplate as autoSelectApi } from '../api/template'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -267,6 +305,52 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const isDragOver = ref(false)
|
||||
|
||||
// Template state
|
||||
const availableTemplates = ref([])
|
||||
const selectedTemplate = ref('custom')
|
||||
const templateLoading = ref(false)
|
||||
const templateAutoSelected = ref(false)
|
||||
|
||||
// Load templates on mount
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getTemplates()
|
||||
if (res.success) {
|
||||
availableTemplates.value = res.templates
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load templates:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Select template and fill prompt
|
||||
const selectTemplate = (tmpl) => {
|
||||
selectedTemplate.value = tmpl.id
|
||||
formData.value.simulationRequirement = tmpl.prompt
|
||||
}
|
||||
|
||||
// Auto-select template from uploaded files
|
||||
const autoSelectTemplate = async () => {
|
||||
if (files.value.length === 0) return
|
||||
|
||||
templateLoading.value = true
|
||||
try {
|
||||
// Extract text from first file (simplified — in production read file content)
|
||||
const text = formData.value.simulationRequirement || files.value[0].name
|
||||
|
||||
const res = await autoSelectApi({ text })
|
||||
if (res.success) {
|
||||
selectedTemplate.value = res.template_id
|
||||
formData.value.simulationRequirement = res.prompt
|
||||
templateAutoSelected.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Auto-select failed:', e)
|
||||
} finally {
|
||||
templateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 文件输入引用
|
||||
const fileInput = ref(null)
|
||||
|
||||
@@ -770,6 +854,79 @@ const startSimulation = () => {
|
||||
padding: 8px; /* 内边距形成双重边框感 */
|
||||
}
|
||||
|
||||
/* Template Section */
|
||||
.template-section {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
}
|
||||
|
||||
.template-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.auto-select-btn {
|
||||
background: linear-gradient(135deg, #FF6B35, #FF8F65);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.auto-select-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auto-select-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
background: #F3F4F6;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 20px;
|
||||
font-size: 0.78rem;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.template-chip:hover {
|
||||
border-color: #FF6B35;
|
||||
background: #FFF7F3;
|
||||
}
|
||||
|
||||
.template-chip.active {
|
||||
background: #FF6B35;
|
||||
color: #fff;
|
||||
border-color: #FF6B35;
|
||||
}
|
||||
|
||||
.template-chip-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.template-chip-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.console-section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -677,5 +677,16 @@
|
||||
"llmSelectAgentFailed": "LLM agent selection failed, using default selection: {error}",
|
||||
"generateInterviewQuestionsFailed": "Failed to generate interview questions: {error}",
|
||||
"generateInterviewSummaryFailed": "Failed to generate interview summary: {error}"
|
||||
},
|
||||
"templates": {
|
||||
"title": "Select simulation template",
|
||||
"autoSelect": "Auto-select from data",
|
||||
"custom": "Write your own",
|
||||
"news_event": "News & Events",
|
||||
"policy_regulation": "Policy & Regulations",
|
||||
"business_ad": "Business & Advertising",
|
||||
"fiction_story": "Fiction & Stories",
|
||||
"social_culture": "Social & Culture",
|
||||
"fillPrompt": "Edit prompt before running"
|
||||
}
|
||||
}
|
||||
@@ -677,5 +677,16 @@
|
||||
"llmSelectAgentFailed": "LLM เลือก Agent ล้มเหลว ใช้การเลือกเริ่มต้น: {error}",
|
||||
"generateInterviewQuestionsFailed": "สร้างคำถามสัมภาษณ์ล้มเหลว: {error}",
|
||||
"generateInterviewSummaryFailed": "สร้างสรุปสัมภาษณ์ล้มเหลว: {error}"
|
||||
},
|
||||
"templates": {
|
||||
"title": "เลือกรูปแบบการจำลอง",
|
||||
"autoSelect": "แนะนำอัตโนมัติจากข้อมูล",
|
||||
"custom": "เขียนเอง",
|
||||
"news_event": "จำลองข่าวและเหตุการณ์",
|
||||
"policy_regulation": "จำลองนโยบายและกฎระเบียบ",
|
||||
"business_ad": "จำลองธุรกิจและโฆษณา",
|
||||
"fiction_story": "จำลองนิยายและเรื่องเล่า",
|
||||
"social_culture": "จำลองสังคมและวัฒนธรรม",
|
||||
"fillPrompt": "แก้ไขคำสั่งก่อนรัน"
|
||||
}
|
||||
}
|
||||
@@ -677,5 +677,16 @@
|
||||
"llmSelectAgentFailed": "LLM选择Agent失败,使用默认选择: {error}",
|
||||
"generateInterviewQuestionsFailed": "生成采访问题失败: {error}",
|
||||
"generateInterviewSummaryFailed": "生成采访摘要失败: {error}"
|
||||
},
|
||||
"templates": {
|
||||
"title": "选择模拟模板",
|
||||
"autoSelect": "从数据自动推荐",
|
||||
"custom": "自行编写",
|
||||
"news_event": "新闻与事件",
|
||||
"policy_regulation": "政策与法规",
|
||||
"business_ad": "商业与广告",
|
||||
"fiction_story": "小说与故事",
|
||||
"social_culture": "社会与文化",
|
||||
"fillPrompt": "运行前编辑指令"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user