diff --git a/backend/app/__init__.py b/backend/app/__init__.py index a128902..fc2e880 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -65,10 +65,12 @@ def create_app(config_class=Config): # 注册蓝图 from .api import graph_bp, simulation_bp, report_bp from .api.template import template_bp + from .api.agent_group import agent_group_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.register_blueprint(agent_group_bp, url_prefix='/api/agent-group') # 健康检查 @app.route('/health') diff --git a/backend/app/api/agent_group.py b/backend/app/api/agent_group.py new file mode 100644 index 0000000..5e93be9 --- /dev/null +++ b/backend/app/api/agent_group.py @@ -0,0 +1,173 @@ +""" +Agent Grouping API +Groups simulation agents into categories and filters them +""" + +import json +import os +from flask import Blueprint, request, jsonify +from ..utils.llm_client import LLMClient +from ..utils.locale import t, get_language_instruction +from ..utils.logger import get_logger + +logger = get_logger('crowdsight.agent_group') + +agent_group_bp = Blueprint('agent_group', __name__) + + +@agent_group_bp.route('/categorize', methods=['POST']) +def categorize_agents(): + """ + Categorize agents into groups based on their profiles. + + Request JSON: + { + "agents": [ + { + "agent_id": 0, + "name": "...", + "profession": "...", + "bio": "...", + "persona": "...", + "interested_topics": [...] + } + ], + "simulation_requirement": "..." + } + + Response JSON: + { + "success": true, + "groups": [ + { + "group_id": "target_audience", + "group_name": "กลุ่มเป้าหมาย", + "default_enabled": true, + "agents": [0, 2, 5] + }, + { + "group_id": "advertiser", + "group_name": "ผู้โฆษณา/แบรนด์", + "default_enabled": false, + "agents": [1, 3] + } + ] + } + """ + try: + data = request.get_json() or {} + agents = data.get('agents', []) + simulation_requirement = data.get('simulation_requirement', '') + + if not agents: + return jsonify({'success': False, 'error': 'No agents provided'}), 400 + + # Build agent summary for LLM + agent_summaries = [] + for i, agent in enumerate(agents): + summary = f"[{i}] {agent.get('name', 'Unknown')} - {agent.get('profession', 'N/A')} - {agent.get('bio', '')[:100]}" + agent_summaries.append(summary) + + agents_text = '\n'.join(agent_summaries) + lang_instruction = get_language_instruction() + + system_prompt = f"""You are an expert at analyzing social simulation agents. Your task is to categorize agents into groups and determine which groups are useful for the simulation. + +{lang_instruction} + +Return JSON format: +{{ + "groups": [ + {{ + "group_id": "unique_english_id", + "group_name": "group name in user's language", + "description": "brief description", + "default_enabled": true/false, + "agent_indices": [0, 1, 2] + }} + ] +}} + +Guidelines: +- Group agents by their ROLE in the simulation context +- Groups that represent the TARGET AUDIENCE, INFLUENCERS, MEDIA, COMPETITORS should be default_enabled=true +- Groups that represent THE ADVERTISER/BRAND ITSELF, ABSTRACT CONCEPTS, MARKETING METADATA should be default_enabled=false +- If the simulation is about an ad/campaign, the company that made the ad should be in a disabled group +- Each agent should be in exactly one group +- Agent indices must match the input indices""" + + user_prompt = f"""Simulation requirement: {simulation_requirement} + +Agents to categorize: +{agents_text} + +Categorize these agents into groups. Mark groups as default_enabled=false if they represent the entity that created the content being simulated (e.g., the advertiser in an ad scenario).""" + + llm = LLMClient() + result = llm.chat_json( + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.3 + ) + + # Validate and clean up + groups = result.get('groups', []) + if not groups: + # Fallback: put all agents in one enabled group + groups = [{ + 'group_id': 'all', + 'group_name': 'All Agents', + 'description': 'All agents', + 'default_enabled': True, + 'agent_indices': list(range(len(agents))) + }] + + return jsonify({ + 'success': True, + 'groups': groups + }) + + except Exception as e: + logger.error(f"Agent categorization failed: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + +@agent_group_bp.route('/filter', methods=['POST']) +def filter_agents(): + """ + Filter agents based on selected groups. + + Request JSON: + { + "agents": [...], // full agent list + "selected_groups": [0, 2] // indices of enabled groups + } + + Response JSON: + { + "success": true, + "selected_agent_ids": [0, 2, 5] + } + """ + try: + data = request.get_json() or {} + agents = data.get('agents', []) + groups = data.get('groups', []) + selected_group_ids = data.get('selected_group_ids', []) + + # Collect agent indices from selected groups + selected_indices = set() + for group in groups: + if group.get('group_id') in selected_group_ids: + selected_indices.update(group.get('agent_indices', [])) + + return jsonify({ + 'success': True, + 'selected_agent_ids': sorted(selected_indices) + }) + + except Exception as e: + logger.error(f"Agent filtering failed: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/frontend/src/api/agentGroup.js b/frontend/src/api/agentGroup.js new file mode 100644 index 0000000..761e843 --- /dev/null +++ b/frontend/src/api/agentGroup.js @@ -0,0 +1,17 @@ +import service from './index' + +/** + * Categorize agents into groups using AI + * @param {Object} data - { agents, simulation_requirement } + */ +export const categorizeAgents = (data) => { + return service.post('/api/agent-group/categorize', data) +} + +/** + * Get selected agent IDs based on enabled groups + * @param {Object} data - { agents, groups, selected_group_ids } + */ +export const filterAgents = (data) => { + return service.post('/api/agent-group/filter', data) +} diff --git a/frontend/src/components/Step2EnvSetup.vue b/frontend/src/components/Step2EnvSetup.vue index 5b724e7..b7905b9 100644 --- a/frontend/src/components/Step2EnvSetup.vue +++ b/frontend/src/components/Step2EnvSetup.vue @@ -111,6 +111,57 @@ + + +
+
+ 🎯 เลือกกลุ่ม Agent สำหรับจำลอง + +
+
+
+ +
+ + {{ profiles[idx]?.username || `Agent ${idx}` }} + + + +{{ group.agent_indices.length - 5 }} + +
+
+
+
+ เลือกแล้ว {{ selectedAgentCount }} จาก {{ profiles.length }} agents +
+
@@ -641,6 +692,7 @@ import { getSimulationConfig, getSimulationConfigRealtime } from '../api/simulation' +import { categorizeAgents as categorizeAgentsApi } from '../api/agentGroup' const { t } = useI18n() @@ -661,6 +713,21 @@ const currentStage = ref('') const progressMessage = ref('') const profiles = ref([]) const entityTypes = ref([]) + +// Agent grouping state +const agentGroups = ref([]) +const groupLoading = ref(false) + +const selectedAgentCount = computed(() => { + if (agentGroups.value.length === 0) return profiles.value.length + let count = 0 + for (const group of agentGroups.value) { + if (group.enabled) { + count += group.agent_indices.length + } + } + return count +}) const expectedTotal = ref(null) const simulationConfig = ref(null) const selectedProfile = ref(null) @@ -768,6 +835,55 @@ const selectProfile = (profile) => { selectedProfile.value = profile } +// Categorize agents into groups using AI +const categorizeAgents = async () => { + if (profiles.value.length === 0) return + + groupLoading.value = true + try { + const res = await categorizeAgentsApi({ + agents: profiles.value.map((p, i) => ({ + agent_id: i, + name: p.username || p.name, + profession: p.profession, + bio: p.bio, + persona: p.persona?.substring(0, 200), + interested_topics: p.interested_topics + })), + simulation_requirement: props.projectData?.simulation_requirement || '' + }) + + if (res.success && res.groups) { + agentGroups.value = res.groups.map(g => ({ + ...g, + enabled: g.default_enabled !== false + })) + emit('add-log', `✅ จัดกลุ่ม Agent เป็น ${res.groups.length} กลุ่ม (เลือก ${selectedAgentCount.value} ตัว)`) + } + } catch (e) { + console.error('Agent categorization failed:', e) + emit('add-log', `❌ การจัดกลุ่มล้มเหลว: ${e.message}`) + } finally { + groupLoading.value = false + } +} + +// Get selected agent IDs (filter out unchecked groups) +const getSelectedAgentIds = () => { + if (agentGroups.value.length === 0) { + // No grouping done — return all + return profiles.value.map((_, i) => i) + } + + const selected = new Set() + for (const group of agentGroups.value) { + if (group.enabled) { + group.agent_indices.forEach(idx => selected.add(idx)) + } + } + return Array.from(selected).sort() +} + // 自动开始准备模拟 const startPrepareSimulation = async () => { if (!props.simulationId) { @@ -1300,6 +1416,120 @@ onUnmounted(() => { display: block; } +/* Agent Groups Section */ +.agent-groups-section { + margin-top: 20px; + padding: 16px; + background: #F8F9FA; + border-radius: 10px; + border: 1px solid #E5E7EB; +} + +.groups-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.groups-title { + font-weight: 600; + font-size: 0.9rem; + color: #1a1a1a; +} + +.categorize-btn { + background: linear-gradient(135deg, #FF6B35, #FF8F65); + color: #fff; + border: none; + border-radius: 6px; + padding: 6px 14px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.categorize-btn:hover { opacity: 0.9; } +.categorize-btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.groups-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.group-card { + padding: 12px; + background: #fff; + border-radius: 8px; + border: 1px solid #E5E7EB; + transition: opacity 0.2s; +} + +.group-card.group-disabled { + opacity: 0.5; +} + +.group-label { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.group-checkbox { + width: 18px; + height: 18px; + accent-color: #FF6B35; +} + +.group-info { + display: flex; + align-items: baseline; + gap: 8px; +} + +.group-name { + font-weight: 600; + font-size: 0.88rem; + color: #1a1a1a; +} + +.group-count { + font-size: 0.75rem; + color: #999; +} + +.group-agents { + margin-top: 8px; + display: flex; + flex-wrap: wrap; + gap: 4px; + padding-left: 28px; +} + +.agent-tag { + background: #E5E7EB; + color: #374151; + padding: 2px 8px; + border-radius: 10px; + font-size: 0.72rem; +} + +.agent-more { + color: #999; + font-size: 0.72rem; + padding: 2px 6px; +} + +.groups-summary { + margin-top: 10px; + text-align: center; + font-size: 0.82rem; + color: #666; +} + /* Profiles Preview */ .profiles-preview { margin-top: 20px;