diff --git a/README.md b/README.md index 0c1d28c..e24b3cb 100644 --- a/README.md +++ b/README.md @@ -11,5 +11,7 @@ Our experimental design involves two phases: one for artists and another for the We plan to continue this research in a second part through a longitudinal field study. +Developer documentation for the creator dataset is in [`src/pages/artist/README.md`](src/pages/artist/README.md). + 1. Kwan, L. Y. -Y., Leung, A. K. -y., & Liou, S. (2018). Culture, creativity, and innovation. Journal of Cross-Cultural Psychology, 49(2), 165–170. https://doi.org/10.1177/0022022117753306s 2. Elisondo, R. (2016). Creativity is Always a Social Process. Creativity. Theories – Research - Applications, 3(2), 2016. 194-210. https://doi.org/10.1515/ctra-2016-0013 diff --git a/server/api/routes/firebaseAPI.ts b/server/api/routes/firebaseAPI.ts index 5fbc4aa..6f257ac 100644 --- a/server/api/routes/firebaseAPI.ts +++ b/server/api/routes/firebaseAPI.ts @@ -11,9 +11,23 @@ const ASSIGNMENT_COLLECTION = "artistAssignment"; router.post("/artist-assignment", async (req, res) => { try { - const { sessionId, passageId, prolificPid } = req.body; - if (!sessionId || !passageId) { - return res.status(400).json({ error: "Missing sessionId or passageId" }); + const { + sessionId, + passageId, + tutorialPassageId, + passagePoolVersion, + prolificPid, + } = req.body; + if ( + !sessionId || + !passageId || + !tutorialPassageId || + !passagePoolVersion + ) { + return res.status(400).json({ + error: + "Missing sessionId, passageId, tutorialPassageId, or passagePoolVersion", + }); } const assignmentRef = db.collection(ASSIGNMENT_COLLECTION).doc(sessionId); @@ -21,8 +35,40 @@ router.post("/artist-assignment", async (req, res) => { const existingAssignment = await transaction.get(assignmentRef); if (existingAssignment.exists) { const existing = existingAssignment.data()!; + const taskPassageId = String( + existing.taskPassageId ?? existing.passageId, + ); + const resolvedTutorialPassageId = String( + existing.tutorialPassageId ?? + (tutorialPassageId === taskPassageId + ? passageId + : tutorialPassageId), + ); + const resolvedPassagePoolVersion = String( + existing.passagePoolVersion ?? "legacy-creator-passages", + ); + + if ( + !existing.taskPassageId || + !existing.tutorialPassageId || + !existing.passagePoolVersion + ) { + transaction.set( + assignmentRef, + { + taskPassageId, + tutorialPassageId: resolvedTutorialPassageId, + passagePoolVersion: resolvedPassagePoolVersion, + }, + { merge: true }, + ); + } + return { - passageId: existing.passageId as string, + passageId: taskPassageId, + taskPassageId, + tutorialPassageId: resolvedTutorialPassageId, + passagePoolVersion: resolvedPassagePoolVersion, condition: existing.condition as "LLM" | "NO_AI", strategy: existing.strategy as string, }; @@ -36,12 +82,22 @@ router.post("/artist-assignment", async (req, res) => { sessionId, prolificPid: prolificPid || null, passageId: String(passageId), + taskPassageId: String(passageId), + tutorialPassageId: String(tutorialPassageId), + passagePoolVersion: String(passagePoolVersion), condition, strategy, assignedAt: FieldValue.serverTimestamp(), }); - return { passageId: String(passageId), condition, strategy }; + return { + passageId: String(passageId), + taskPassageId: String(passageId), + tutorialPassageId: String(tutorialPassageId), + passagePoolVersion: String(passagePoolVersion), + condition, + strategy, + }; }); res.json(assignment); diff --git a/src/components/chatbot/Chatbot.tsx b/src/components/chatbot/Chatbot.tsx index dc36fd5..5895ffe 100644 --- a/src/components/chatbot/Chatbot.tsx +++ b/src/components/chatbot/Chatbot.tsx @@ -10,18 +10,21 @@ import { FiSend } from "react-icons/fi"; import { Button, Textarea } from "@chakra-ui/react"; import { nanoid } from "nanoid"; import type { - ChatOpening, + ChatAvailability, + ChatInputActivity, + ChatInputSource as ChatInputSourceType, LlmRequestLog, Message, Stage, } from "../../types"; -import { Role } from "../../types"; +import { ChatInputSource, MessageKind, Role } from "../../types"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { DataContext } from "../../App"; import { createAssistantMessage, IDLE_NUDGE_MESSAGES, + STAGE_OPENING_MESSAGES, } from "../../consts/chatMessages"; interface ChatTabProps { @@ -31,12 +34,14 @@ interface ChatTabProps { selectedWordIndexes?: number[]; passage: string; chatReady?: boolean; - onChatOpened?: (opening: ChatOpening) => void; + initialInputActivity?: ChatInputActivity; + onChatAvailable?: (availability: ChatAvailability) => void; + onInputActivityUpdate?: (activity: ChatInputActivity) => void; onRequestUpdate?: (request: LlmRequestLog) => void; } export const BLACKOUT_ASSISTANT_PROMPT_VERSION = - "blackout-assistant-2026-08-04-v1"; + "blackout-assistant-2026-08-05-v2"; /** * Keep the locator-excerpt convention identical across both stages so users @@ -50,9 +55,10 @@ Blackout poetry: the poet starts with an existing passage and creates a poem by Grounding: - Work only with the passage provided below. Never reference or substitute any other text. -- When you point to a specific passage word, show it in a short excerpt containing two or three nearby passage words in total when available. Bold only the word you are pointing to. The unbolded words are only a locator to help the user find it; they are not part of the suggestion. +- When you point to a specific passage word, show it in a short excerpt containing two or three nearby passage words in total when available. Bold only the word you are pointing to and italicize every surrounding locator word. For example: “*nights are* **clear**” or “*sharp,* **glittering** *sunshine*”. The italicized words are only locators to help the user find the bolded word; they are not part of the suggestion. - Quote passage words exactly as written, keep multiple suggested words in passage order, and point to at most five words in a single response. - Use bold only for passage words you are pointing to, never for general emphasis. +- Use italics only for the surrounding locator words in these excerpts, never for general emphasis. - Never suggest a word that does not appear in the passage. Style and behavior: @@ -161,7 +167,9 @@ export default function ChatTab({ stage, passage, chatReady = true, - onChatOpened, + initialInputActivity, + onChatAvailable, + onInputActivityUpdate, onRequestUpdate, }: ChatTabProps) { const context = useContext(DataContext); @@ -171,7 +179,18 @@ export default function ChatTab({ const chatContainerRef = useRef(null); const timeoutRef = useRef | null>(null); const stageStartMessageCountRef = useRef(messages.length); - const hasLoggedOpeningRef = useRef(false); + const hasLoggedAvailabilityRef = useRef(false); + const inputRef = useRef(""); + const hasDraftRef = useRef(false); + const inputActivityRef = useRef( + initialInputActivity ?? { + stage, + focusCount: 0, + draftStartCount: 0, + abandonedDraftCount: 0, + hasUnsentDraft: false, + }, + ); const [isLLMLoading, setIsLLMLoading] = useState(false); const [input, setInput] = useState(""); @@ -205,10 +224,29 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, }, [passage, selectedWordIndexes, stage]); useEffect(() => { - if (!chatReady || hasLoggedOpeningRef.current) return; - hasLoggedOpeningRef.current = true; - onChatOpened?.({ stage, timestamp: new Date() }); - }, [chatReady, onChatOpened, stage]); + if (!chatReady || hasLoggedAvailabilityRef.current) return; + hasLoggedAvailabilityRef.current = true; + + const availableAt = new Date(); + setMessages((previousMessages) => { + const alreadyHasOpening = previousMessages.some( + (message) => + message.stage === stage && + message.kind === MessageKind.STAGE_OPENING, + ); + return alreadyHasOpening + ? previousMessages + : [ + ...previousMessages, + createAssistantMessage( + STAGE_OPENING_MESSAGES[stage], + stage, + MessageKind.STAGE_OPENING, + ), + ]; + }); + onChatAvailable?.({ stage, availableAt }); + }, [chatReady, onChatAvailable, setMessages, stage]); useEffect(() => { const element = chatContainerRef.current; @@ -226,7 +264,11 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, timeoutRef.current = setTimeout(() => { setMessages((previousMessages) => [ ...previousMessages, - createAssistantMessage(IDLE_NUDGE_MESSAGES[stage]), + createAssistantMessage( + IDLE_NUDGE_MESSAGES[stage], + stage, + MessageKind.IDLE_NUDGE, + ), ]); setHasShownIdleNudge(true); }, 40000); @@ -245,8 +287,66 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, stage, ]); - const sendMessage = async (messageContent?: string) => { - const content = messageContent || input; + const publishInputActivity = (activity: ChatInputActivity) => { + inputActivityRef.current = activity; + onInputActivityUpdate?.(activity); + }; + + const handleInputFocus = () => { + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + firstFocusedAt: activity.firstFocusedAt ?? new Date(), + focusCount: activity.focusCount + 1, + }); + }; + + const handleInputChange = (value: string) => { + const hadDraft = hasDraftRef.current; + const hasDraft = Boolean(value.trim()); + inputRef.current = value; + hasDraftRef.current = hasDraft; + setInput(value); + + if (!hadDraft && hasDraft) { + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + firstTypedAt: activity.firstTypedAt ?? new Date(), + draftStartCount: activity.draftStartCount + 1, + hasUnsentDraft: true, + }); + } else if (hadDraft && !hasDraft) { + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + abandonedDraftCount: activity.abandonedDraftCount + 1, + hasUnsentDraft: false, + }); + } + }; + + const markDraftSubmittedOrReplaced = ( + inputSource: ChatInputSourceType, + ) => { + if (!hasDraftRef.current) return; + + hasDraftRef.current = false; + const activity = inputActivityRef.current; + publishInputActivity({ + ...activity, + abandonedDraftCount: + activity.abandonedDraftCount + + (inputSource === ChatInputSource.SUGGESTION ? 1 : 0), + hasUnsentDraft: false, + }); + }; + + const sendMessage = async ( + messageContent?: string, + inputSource: ChatInputSourceType = ChatInputSource.TYPED, + ) => { + const content = messageContent ?? inputRef.current; if (!content.trim() || isLLMLoading) return; if (timeoutRef.current) { @@ -259,6 +359,9 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, role: Role.ARTIST, content, timestamp: new Date(), + stage, + kind: MessageKind.USER_MESSAGE, + inputSource, }; const requestId = nanoid(); let requestLog: LlmRequestLog = { @@ -268,6 +371,7 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, userMessageContent: content, requestedAt: new Date(), status: "STARTED", + inputSource, systemPrompt: systemMessage.content, promptVersion: BLACKOUT_ASSISTANT_PROMPT_VERSION, }; @@ -281,6 +385,8 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, setMarkdownOutput(""); setSendError(null); setMessages((prev) => [...prev, artistMessage]); + markDraftSubmittedOrReplaced(inputSource); + inputRef.current = ""; setInput(""); setIsLLMLoading(true); @@ -320,6 +426,8 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, role: Role.LLM, content: fullText, timestamp: new Date(), + stage, + kind: MessageKind.LLM_RESPONSE, }; requestLog = { ...requestLog, @@ -347,9 +455,15 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, setMessages((previousMessages) => previousMessages.filter((message) => message.id !== artistMessage.id), ); - setInput((currentInput) => - currentInput.trim() ? currentInput : content, - ); + if (!inputRef.current.trim()) { + inputRef.current = content; + hasDraftRef.current = true; + setInput(content); + publishInputActivity({ + ...inputActivityRef.current, + hasUnsentDraft: true, + }); + } } finally { setIsLLMLoading(false); } @@ -363,7 +477,7 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, }; const handlePromptSelection = (prompt: string) => { - sendMessage(prompt); + sendMessage(prompt, ChatInputSource.SUGGESTION); }; return ( @@ -447,7 +561,8 @@ CURRENT SELECTED WORDS (in passage order): ${selectedWords || "none yet"}`, >