Skip to content

Commit f5dc069

Browse files
authored
Merge pull request #66 from BYU-PCCL/staging
adding opinion-dynamics
2 parents 6f06677 + 2ffac57 commit f5dc069

25 files changed

+1835
-1
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"id": "opinion-dynamics",
3+
"type": "web",
4+
"title": "Opinion Dynamics Network",
5+
"description": "Interactive opinion dynamics simulation - watch how social media posts influence group opinions in real-time.",
6+
"action_hints": ["post a message to the network", "toggle the simulation", "reset the simulation"],
7+
"layout": "full",
8+
"lifetime": 180,
9+
"queueable": true
10+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/** @jsxImportSource @emotion/react */
2+
/**
3+
* SimulationControls message formats:
4+
* Play: { type: "simulation", action: "play" }
5+
* Pause: { type: "simulation", action: "pause" }
6+
* Reset: { type: "simulation", action: "reset" }
7+
* Send: { type: "message", value: message }
8+
*/
9+
import React, { useState, useCallback } from "react";
10+
import { css } from "@emotion/react";
11+
import IconButton from "@material-ui/core/IconButton";
12+
import Button from "@material-ui/core/Button";
13+
import TextField from "@material-ui/core/TextField";
14+
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
15+
import PauseIcon from "@material-ui/icons/Pause";
16+
import RefreshIcon from "@material-ui/icons/Refresh";
17+
import SendIcon from "@material-ui/icons/Send";
18+
import { useMessaging } from "@footron/controls-client";
19+
20+
const containerStyle = css`
21+
display: flex;
22+
flex-direction: column;
23+
gap: 20px;
24+
padding: 24px;
25+
max-width: 350px;
26+
`;
27+
28+
const buttonRowStyle = css`
29+
display: flex;
30+
flex-direction: row;
31+
gap: 16px;
32+
align-items: center;
33+
`;
34+
35+
const SimulationControls = () => {
36+
const [simulationRunning, setSimulationRunning] = useState(false);
37+
const [message, setMessage] = useState("");
38+
const [sending, setSending] = useState(false);
39+
const { sendMessage } = useMessaging();
40+
41+
const handleToggleSimulation = useCallback(async () => {
42+
setSimulationRunning((prev) => {
43+
const next = !prev;
44+
sendMessage({ type: "simulation", action: next ? "play" : "pause" });
45+
return next;
46+
});
47+
}, [sendMessage]);
48+
49+
const handleReset = useCallback(() => {
50+
setSimulationRunning(false);
51+
sendMessage({ type: "simulation", action: "reset" });
52+
}, [sendMessage]);
53+
54+
const handleSend = useCallback(async () => {
55+
if (!message.trim()) return;
56+
setSending(true);
57+
try {
58+
await Promise.resolve(sendMessage({ type: "message", value: message }));
59+
setMessage("");
60+
} catch (e) {
61+
// Optionally handle error
62+
} finally {
63+
setSending(false);
64+
}
65+
}, [message, sendMessage]);
66+
67+
const handleInputKeyPress = (e) => {
68+
if (e.key === "Enter" && !e.shiftKey) {
69+
e.preventDefault();
70+
handleSend();
71+
}
72+
};
73+
74+
return (
75+
<div css={containerStyle}>
76+
<div css={buttonRowStyle}>
77+
<IconButton onClick={handleToggleSimulation} color="primary" aria-label="toggle simulation">
78+
{simulationRunning ? <PauseIcon /> : <PlayArrowIcon />}
79+
</IconButton>
80+
<IconButton onClick={handleReset} color="secondary" aria-label="reset simulation">
81+
<RefreshIcon />
82+
</IconButton>
83+
</div>
84+
<TextField
85+
label="Enter your message..."
86+
multiline
87+
minRows={2}
88+
maxRows={4}
89+
variant="outlined"
90+
value={message}
91+
onChange={(e) => setMessage(e.target.value)}
92+
onKeyPress={handleInputKeyPress}
93+
disabled={sending}
94+
fullWidth
95+
/>
96+
<Button
97+
variant="contained"
98+
color="primary"
99+
endIcon={<SendIcon />}
100+
onClick={handleSend}
101+
disabled={sending || !message.trim()}
102+
>
103+
{sending ? "Analyzing..." : "Send"}
104+
</Button>
105+
</div>
106+
);
107+
};
108+
109+
export default SimulationControls;
64.2 KB
Loading
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// WARNING: Storing API keys in frontend code exposes them to users. Use at your own risk!
2+
let OPENAI_API_KEY = null;
3+
4+
// Load API key from key file
5+
async function loadApiKey() {
6+
const response = await fetch('../openai_key.json');
7+
if (!response.ok) {
8+
throw new Error(`Failed to load OpenAI API key: ${response.status}`);
9+
}
10+
const keyData = await response.json();
11+
OPENAI_API_KEY = keyData.openai_api_key;
12+
console.log('API key loaded from openai_key.json');
13+
}
14+
15+
// Initialize API key loading
16+
loadApiKey();
17+
18+
export { OPENAI_API_KEY };
19+
20+
export async function analyzePostWithChatGPT(post, opinionAxes, maxRetries = 5) {
21+
const systemPrompt = (() => {
22+
let prompt = "You analyze social media posts and output opinion vectors with high accuracy. ";
23+
prompt += "Focus on the ACTUAL OPINION EXPRESSED, not just keywords. Consider context, tone, and intent.\n";
24+
prompt += "For each topic, rate the opinion on a scale of 0.0 to 1.0 where:\n";
25+
for (let i = 0; i < opinionAxes.length; i++) {
26+
prompt += `\nTopic ${i + 1}: ${opinionAxes[i].name}\n`;
27+
prompt += `0.0 = Strongly agrees with: ${opinionAxes[i].con}\n`;
28+
prompt += `1.0 = Strongly agrees with: ${opinionAxes[i].pro}\n`;
29+
prompt += "0.5 = Neutral or topic not addressed\n";
30+
}
31+
prompt += "\nEXAMPLES:\n";
32+
prompt += "'I love pineapple on pizza! It's a fantastic combination.' = [1.0] (strongest positive)\n";
33+
prompt += "'I'm a fan of pineapple on pizza, it's pretty good.' = [0.8] (clearly positive)\n";
34+
prompt += "'I guess pineapple on pizza is fine.' = [0.6] (mildly positive)\n";
35+
prompt += "'I don't really like pineapple on pizza.' = [0.4] (mildly negative)\n";
36+
prompt += "'I really don't like pineapple on pizza.' = [0.2] (clearly negative)\n";
37+
prompt += "'Absolutely not. Pineapple on pizza is a hard pass for me.' = [0.0] (strongest negative)\n";
38+
prompt += "\nOutput ONLY a JavaScript array of numbers, e.g. [0.8, 0.2]";
39+
return prompt;
40+
})();
41+
42+
function validateOpinionVector(result) {
43+
try {
44+
let vector = eval(result);
45+
if (!Array.isArray(vector)) return null;
46+
if (!vector.every(x => typeof x === 'number')) return null;
47+
if (!vector.every(x => x >= 0 && x <= 1)) return null;
48+
while (vector.length < opinionAxes.length) vector.push(0.5);
49+
return vector.slice(0, opinionAxes.length);
50+
} catch {
51+
return null;
52+
}
53+
}
54+
55+
for (let attempt = 0; attempt < maxRetries; attempt++) {
56+
try {
57+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
58+
method: 'POST',
59+
headers: {
60+
'Content-Type': 'application/json',
61+
'Authorization': `Bearer ${OPENAI_API_KEY}`
62+
},
63+
body: JSON.stringify({
64+
model: 'gpt-4o-mini',
65+
messages: [
66+
{ role: 'system', content: systemPrompt },
67+
{ role: 'user', content: `Analyze this post: ${post}` }
68+
]
69+
})
70+
});
71+
const data = await response.json();
72+
const result = data.choices[0].message.content.trim();
73+
const vector = validateOpinionVector(result);
74+
if (vector !== null) return vector;
75+
} catch (e) {
76+
if (attempt === maxRetries - 1) {
77+
throw new Error(`Failed after ${maxRetries} attempts: ${e}`);
78+
}
79+
continue;
80+
}
81+
}
82+
throw new Error(`Failed to get valid opinion vector after ${maxRetries} attempts for post: '${post.slice(0, 50)}...'`);
83+
}

experiences/opinion-dynamics/web/assets/d3/d3.v7.min.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

experiences/opinion-dynamics/web/assets/fontawesome/css/all.min.css

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)