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:
Kunthawat Greethong
2026-06-26 11:44:55 +07:00
parent 3c4c2183c7
commit 166ef73ad2
8 changed files with 434 additions and 1 deletions

View File

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

View 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"
}
}
]
}

View 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`)
}

View File

@@ -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;
}

View File

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

View File

@@ -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": "แก้ไขคำสั่งก่อนรัน"
}
}

View File

@@ -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": "运行前编辑指令"
}
}