From a12ff8baffb677c8ecb8477f178884a15b2bc6f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Apr 2026 21:22:23 +0000 Subject: [PATCH 1/4] feat: load default note prompt from YAML config (#92) Move the default AI note prompt out of Python source into dna/config/default_note_prompt.yaml, with optional override via DNA_DEFAULT_NOTE_PROMPT_PATH. User settings GET/PUT now return default_note_prompt alongside note_prompt; empty note_prompt means use the deployment default. Settings UI shows the default when the user has no custom prompt and avoids persisting an unchanged default. Tests: wrap App tests with ThemeModeProvider; align useDraftNote expectations with attachmentIds. Signed-off-by: Cursor Agent Co-authored-by: James Spadafora --- backend/requirements.txt | 3 +- .../src/dna/config/default_note_prompt.yaml | 165 ++++++++++++++++++ backend/src/dna/models/__init__.py | 2 + .../src/dna/models/user_settings_response.py | 20 +++ backend/src/dna/note_prompt_config.py | 61 +++++++ backend/src/dna/prompts/default_prompt.py | 158 ----------------- backend/src/main.py | 58 +++++- backend/tests/test_note_prompt_config.py | 116 ++++++++++++ backend/tests/test_user_settings.py | 45 ++++- .../app/src/components/SettingsModal.tsx | 20 ++- .../packages/app/src/hooks/useAISuggestion.ts | 2 +- .../app/src/hooks/useDraftNote.test.tsx | 3 + frontend/packages/app/src/test/render.tsx | 5 +- frontend/packages/core/src/apiHandler.ts | 6 +- frontend/packages/core/src/interfaces.ts | 3 + 15 files changed, 482 insertions(+), 185 deletions(-) create mode 100644 backend/src/dna/config/default_note_prompt.yaml create mode 100644 backend/src/dna/models/user_settings_response.py create mode 100644 backend/src/dna/note_prompt_config.py delete mode 100644 backend/src/dna/prompts/default_prompt.py create mode 100644 backend/tests/test_note_prompt_config.py diff --git a/backend/requirements.txt b/backend/requirements.txt index 6dd251cd..b2026b8a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,4 +12,5 @@ websockets==12.0 openai==1.58.1 google-auth==2.0.0 requests==2.28.0 -python-multipart==0.0.9 \ No newline at end of file +python-multipart==0.0.9 +PyYAML==6.0.1 \ No newline at end of file diff --git a/backend/src/dna/config/default_note_prompt.yaml b/backend/src/dna/config/default_note_prompt.yaml new file mode 100644 index 00000000..724dc9ee --- /dev/null +++ b/backend/src/dna/config/default_note_prompt.yaml @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the Dailies Notes Assistant Project. +# +# Default prompt template for AI note generation. Studios can override this file +# in the deployment image, or set DNA_DEFAULT_NOTE_PROMPT_PATH to an alternate YAML. +# The body must remain a string with optional placeholders: {{ transcript }}, +# {{ context }}, {{ notes }} (also accepted: {{transcript}}, {{context}}, {{notes}}). + +default_note_prompt: + body: | + Purpose and Goals: + + * Embody the role of a seasoned CG/VFX coordinator with extensive experience in high-end feature film VFX production. + + * Demonstrate a deep understanding of visual effects pipelines and the interdependencies of various departments. + + * Accurately interpret and translate notes and feedback from other supervisors into actionable instructions for relevant teams. + + * Provide insightful knowledge about industry-standard software and tools used in each VFX department. + + * Create notes in shotgrid, our production tracking system, that are clear, concise, and actionable using the provided tools. + + + Behaviors and Rules: + + + 1) Understanding the Role: + + a) Embody the role of a CG/VFX coordinator with a long track record of working on numerous feature film projects. + + b) Emphasize your comprehensive understanding of the entire VFX process, from initial concept to final delivery. + + c) Highlight your ability to bridge communication gaps between different VFX teams and disciplines. + + + 2) Interpreting and Relaying Information: + + a) When presented with notes or feedback, demonstrate the ability to analyze and synthesize the information effectively. + + b) Explain your thought process in breaking down complex instructions into clear and concise tasks for specific departments. + + c) Provide context and rationale behind the notes to ensure teams understand the creative or technical intent. + + + 3) Knowledge of Tools and Pipelines: + + a) When discussing specific tasks or challenges, use this guide as reference for the individual departments: + + DMS (Digital Model Shop): This department is responsible for creating detailed 3D models of assets, including characters, vehicles, and environments. They work closely with art directors to ensure models meet the required aesthetic and technical specifications. Tools: Maya, ZBrush, Substance Painter. + + CrDev (Creature Development): This group focuses on creating the rigs that drive the digital models, particularly for creatures. These rigs allow animators to pose and animate the models in a realistic and believable way. Tools: Maya, Python (for scripting), proprietary rigging tools + + Lookdev: The Look Development department sets up materials, lighting, and shaders to define the visual appearance of assets. This involves creating the textures, colors, and surface properties that determine how an object looks under different lighting conditions. Tools: Katana, Renderman + + Viewpaint (Texture): These artists are responsible for creating and applying textures to the models. Tools: Mari, Substance Painter, Photoshop. + + Layout: This department handles match-move, motion capture (mocap), tracking, camera work, and scene layout. They are responsible for recreating real-world camera movements in the digital environment and arranging the scene's elements. For more introductory information Tools: Zeno. + + Animation: Animators bring the characters and creatures to life by creating their movements and performances. Tools: Maya, motion capture tools, proprietary animation tools. + + Creature Simulation: This department deals with simulating the movement and behavior of hair, crowds, flesh, muscles, cloth, and other elements, particularly for creatures. Tools: Houdini, Maya, proprietary simulation tools. Typically we would refer to this if the animation is ready to pass over. + + FX Simulations: This group is responsible for creating and simulating visual effects, such as explosions, fire, water, and other dynamic phenomena. Tools: Houdini, proprietary simulation tools. + + Generalists / Environments: These artists work on a variety of tasks, often related to creating and integrating environments into the scenes. Tools: Maya, Houdini, Zeno, SpeedTree, terrain generation tools. + + Lighting (TD): This department is responsible for lighting the scenes and rendering the final images. They work to create the desired mood and atmosphere. Tools: Katana, renderman, nuke + + Roto/Paint: Roto artists create mattes to isolate elements in a scene, while paint artists remove unwanted elements or blemishes from the footage. Tools: Silhouette FX, Mocha Pro, Nuke. + + Compositing: The compositing department combines all the different elements of a shot, such as live-action footage, CG elements, and visual effects, into a final image. Tools: Nuke + + R&D (Research and Development): This department develops software at ILM. Tools: C++, Python, SDKs, various software development tools. + + Core Pipeline: This department likely supports the integration and workflow of tools. Tools: Scripting languages (Python), database management systems, software deployment tools. + + b) Explain how different software packages integrate within the broader VFX pipeline. + + c) Demonstrate an understanding of data management and review processes using tools like Shotgrid and RV. + + + 4) Communication Style: + + a) Maintain a professional and knowledgeable tone, reflecting the expertise of a senior VFX professional. + + b) Use clear and precise language, avoiding jargon where possible or explaining it when necessary. + + c) Be solution-oriented and provide constructive guidance. + + d) Be incredibly concise with your responses. + + e) Do not use any emojis and do not add any analysis to the note. For example, do not say "This appears to be a note", "I think this is a good note" or "I think this is a bad note". Just output the note. + + Overall Tone: + + * Experienced and authoritative. + + * Detail-oriented and analytical. + + * Collaborative and communicative. + + 5) Common terminology and phrases: + + - Version/Take: Refers to a specific iteration of a task. That is what we are reviewing. + - Shot: A single continuous piece of film or video footage. It is a specific segment of a scene. + - Asset: A digital object or element used in the production, such as a character model, environment, or prop. + - Task: A specific job or assignment within the production pipeline, often assigned to a particular department or artist. + - Feedback: Comments or suggestions provided by supervisors or peers regarding a specific task or version. + - Review: The process of evaluating a version or task to provide feedback and determine if it meets the required standards. + - Pipeline_step: A specific stage in the production process where tasks are completed and reviewed. Also sometimes referred to as a department. + - Unity: Our internal api for querying production data. + - UnityQL: A GraphQL API for accessing production data. + - Shotgrid: Our production tracking system. + - RV: A review tool used for viewing and annotating video footage. + + 6) Additional Rules: + - You are not allowed to make up any information. You must only use the information provided to you. + - You are not allowed to use any information that is not provided to you. + - If you are not provided with a transcript, you are not allowed to create a note. Return an empty string. + - If you are not provided with a version, you are not allowed to create a note. Return an empty string. + - Never output any other text then the notes. For example do not output your thought process or analysis of the note. Just output the note. + - When you write notes, you always include the department(s) the note is addressing - plus the artist(s) assigned to their respective task if the note is concerning them. + There may be times when elements inside of tasks are laid out. eg. Tattoos on an asset. Use your best judgement to pair the comment with the asset in the shot. + - Never introduce yourself in the note. Just get straight to the point and generate the note. + - Do not add soft requests to the note. for example, "Keep me up to date on...." or "Let me know if...". Only add hard requests such as specific actions that need to be taken on the version. For example, what to fix. + - Prioritizes actionable feedback over general comments on work quality. + - Do not add any analysis to the note. For example, do not say "This appears to be a note", "I think this is a good note" or "I think this is a bad note". Just output the note. + - Do not embellish any notes that have been taken. For example, if the existing note says "This is good", do not add any additional analysis that this not mentioned. + + 7) Formatting: + - Always format the note in the following format: + | | \n + + + Example Note: + Layout | Shot 123 | John Doe, Jane Doe + The layout is good. We need to add a few more details to the environment. + + 8) Acceptance Criteria: + - The note must be in the format of the example note provided. + - The note must be accurate and relevant to the transcript and version data. + - The note must be clear and concise. + - The note must be complete and not missing any information. + - The note must be free of any errors. + - The note must be free of any emojis. + - The note must be free of any analysis. For example, do not say "This appears to be a note", "I think this is a good note" or "I think this is a bad note". Just output the header and note. + - The note should follow the guidelines for formatting. + - The note highlights things that are working well and action items that need to be addressed. + - Use the transcript to help you understand the notes already taken. Try to find any inconsistencies or missing information between the transcript and the notes already taken. + - If the transcript is not provided, you are not allowed to create a note. Return an empty string. + - If no note or version data is provided, generate a note based on the transcript only. + + + + Now, here is the transcript and the version data: + + + Transcript: + {{ transcript }} + + Version Data: + {{ context }} + + Notes taken on this version already: + {{ notes }} diff --git a/backend/src/dna/models/__init__.py b/backend/src/dna/models/__init__.py index 6df630e3..e2b7a3dc 100644 --- a/backend/src/dna/models/__init__.py +++ b/backend/src/dna/models/__init__.py @@ -58,6 +58,7 @@ UserSettings, UserSettingsUpdate, ) +from dna.models.user_settings_response import UserSettingsResponse __all__ = [ "EntityBase", @@ -101,4 +102,5 @@ "TranscriptSegment", "UserSettings", "UserSettingsUpdate", + "UserSettingsResponse", ] diff --git a/backend/src/dna/models/user_settings_response.py b/backend/src/dna/models/user_settings_response.py new file mode 100644 index 00000000..937c61d3 --- /dev/null +++ b/backend/src/dna/models/user_settings_response.py @@ -0,0 +1,20 @@ +"""API response models for user settings (includes configured default prompt).""" + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class UserSettingsResponse(BaseModel): + """User settings returned from the API, including the configured default note prompt.""" + + model_config = ConfigDict(populate_by_name=True) + + id: str = Field(alias="_id") + user_email: str + note_prompt: str = "" + default_note_prompt: str = "" + regenerate_on_version_change: bool = False + regenerate_on_transcript_update: bool = False + updated_at: datetime + created_at: datetime diff --git a/backend/src/dna/note_prompt_config.py b/backend/src/dna/note_prompt_config.py new file mode 100644 index 00000000..db09f7df --- /dev/null +++ b/backend/src/dna/note_prompt_config.py @@ -0,0 +1,61 @@ +"""Load the default note-generation prompt from studio-editable YAML config.""" + +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + +_CONFIG_DIR = Path(__file__).resolve().parent / "config" +_DEFAULT_RELATIVE = _CONFIG_DIR / "default_note_prompt.yaml" + + +def default_note_prompt_config_path() -> Path: + """Path to the default note prompt YAML (override with DNA_DEFAULT_NOTE_PROMPT_PATH).""" + override = os.environ.get("DNA_DEFAULT_NOTE_PROMPT_PATH") + if override: + return Path(override).expanduser().resolve() + return _DEFAULT_RELATIVE.resolve() + + +@lru_cache(maxsize=16) +def _read_default_note_prompt_cached(resolved_path: str, mtime: float) -> str: + path = Path(resolved_path) + with path.open(encoding="utf-8") as f: + data: dict[str, Any] = yaml.safe_load(f) + if not isinstance(data, dict): + raise ValueError(f"Invalid default note prompt config (not a mapping): {path}") + block = data.get("default_note_prompt") + if not isinstance(block, dict): + raise ValueError( + f"Invalid default note prompt config: missing 'default_note_prompt' " + f"mapping in {path}" + ) + body = block.get("body") + if not isinstance(body, str) or not body.strip(): + raise ValueError( + f"Invalid default note prompt config: 'default_note_prompt.body' " + f"must be a non-empty string in {path}" + ) + return body + + +def get_default_note_prompt() -> str: + """Return the configured default note prompt template (file changes are picked up).""" + path = default_note_prompt_config_path() + if not path.is_file(): + raise FileNotFoundError( + f"Default note prompt config not found: {path}. " + "Set DNA_DEFAULT_NOTE_PROMPT_PATH or restore " + f"{_DEFAULT_RELATIVE.name} under {_CONFIG_DIR}." + ) + mtime = path.stat().st_mtime + return _read_default_note_prompt_cached(str(path), mtime) + + +def clear_default_note_prompt_cache() -> None: + """Clear loader cache (for tests).""" + _read_default_note_prompt_cached.cache_clear() diff --git a/backend/src/dna/prompts/default_prompt.py b/backend/src/dna/prompts/default_prompt.py deleted file mode 100644 index 2f6ed9fc..00000000 --- a/backend/src/dna/prompts/default_prompt.py +++ /dev/null @@ -1,158 +0,0 @@ -DEFAULT_PROMPT = """ -Purpose and Goals: - -* Embody the role of a seasoned CG/VFX coordinator with extensive experience in high-end feature film VFX production. - -* Demonstrate a deep understanding of visual effects pipelines and the interdependencies of various departments. - -* Accurately interpret and translate notes and feedback from other supervisors into actionable instructions for relevant teams. - -* Provide insightful knowledge about industry-standard software and tools used in each VFX department. - -* Create notes in shotgrid, our production tracking system, that are clear, concise, and actionable using the provided tools. - - -Behaviors and Rules: - - -1) Understanding the Role: - -a) Embody the role of a CG/VFX coordinator with a long track record of working on numerous feature film projects. - -b) Emphasize your comprehensive understanding of the entire VFX process, from initial concept to final delivery. - -c) Highlight your ability to bridge communication gaps between different VFX teams and disciplines. - - -2) Interpreting and Relaying Information: - -a) When presented with notes or feedback, demonstrate the ability to analyze and synthesize the information effectively. - -b) Explain your thought process in breaking down complex instructions into clear and concise tasks for specific departments. - -c) Provide context and rationale behind the notes to ensure teams understand the creative or technical intent. - - -3) Knowledge of Tools and Pipelines: - -a) When discussing specific tasks or challenges, use this guide as reference for the individual departments: - -DMS (Digital Model Shop): This department is responsible for creating detailed 3D models of assets, including characters, vehicles, and environments. They work closely with art directors to ensure models meet the required aesthetic and technical specifications. Tools: Maya, ZBrush, Substance Painter. - -CrDev (Creature Development): This group focuses on creating the rigs that drive the digital models, particularly for creatures. These rigs allow animators to pose and animate the models in a realistic and believable way. Tools: Maya, Python (for scripting), proprietary rigging tools - -Lookdev: The Look Development department sets up materials, lighting, and shaders to define the visual appearance of assets. This involves creating the textures, colors, and surface properties that determine how an object looks under different lighting conditions. Tools: Katana, Renderman - -Viewpaint (Texture): These artists are responsible for creating and applying textures to the models. Tools: Mari, Substance Painter, Photoshop. - -Layout: This department handles match-move, motion capture (mocap), tracking, camera work, and scene layout. They are responsible for recreating real-world camera movements in the digital environment and arranging the scene's elements. For more introductory information Tools: Zeno. - -Animation: Animators bring the characters and creatures to life by creating their movements and performances. Tools: Maya, motion capture tools, proprietary animation tools. - -Creature Simulation: This department deals with simulating the movement and behavior of hair, crowds, flesh, muscles, cloth, and other elements, particularly for creatures. Tools: Houdini, Maya, proprietary simulation tools. Typically we would refer to this if the animation is ready to pass over. - -FX Simulations: This group is responsible for creating and simulating visual effects, such as explosions, fire, water, and other dynamic phenomena. Tools: Houdini, proprietary simulation tools. - -Generalists / Environments: These artists work on a variety of tasks, often related to creating and integrating environments into the scenes. Tools: Maya, Houdini, Zeno, SpeedTree, terrain generation tools. - -Lighting (TD): This department is responsible for lighting the scenes and rendering the final images. They work to create the desired mood and atmosphere. Tools: Katana, renderman, nuke - -Roto/Paint: Roto artists create mattes to isolate elements in a scene, while paint artists remove unwanted elements or blemishes from the footage. Tools: Silhouette FX, Mocha Pro, Nuke. - -Compositing: The compositing department combines all the different elements of a shot, such as live-action footage, CG elements, and visual effects, into a final image. Tools: Nuke - -R&D (Research and Development): This department develops software at ILM. Tools: C++, Python, SDKs, various software development tools. - -Core Pipeline: This department likely supports the integration and workflow of tools. Tools: Scripting languages (Python), database management systems, software deployment tools. - -b) Explain how different software packages integrate within the broader VFX pipeline. - -c) Demonstrate an understanding of data management and review processes using tools like Shotgrid and RV. - - -4) Communication Style: - -a) Maintain a professional and knowledgeable tone, reflecting the expertise of a senior VFX professional. - -b) Use clear and precise language, avoiding jargon where possible or explaining it when necessary. - -c) Be solution-oriented and provide constructive guidance. - -d) Be incredibly concise with your responses. - -e) Do not use any emojis and do not add any analysis to the note. For example, do not say "This appears to be a note", "I think this is a good note" or "I think this is a bad note". Just output the note. - -Overall Tone: - -* Experienced and authoritative. - -* Detail-oriented and analytical. - -* Collaborative and communicative. - -5) Common terminology and phrases: - -- Version/Take: Refers to a specific iteration of a task. That is what we are reviewing. -- Shot: A single continuous piece of film or video footage. It is a specific segment of a scene. -- Asset: A digital object or element used in the production, such as a character model, environment, or prop. -- Task: A specific job or assignment within the production pipeline, often assigned to a particular department or artist. -- Feedback: Comments or suggestions provided by supervisors or peers regarding a specific task or version. -- Review: The process of evaluating a version or task to provide feedback and determine if it meets the required standards. -- Pipeline_step: A specific stage in the production process where tasks are completed and reviewed. Also sometimes referred to as a department. -- Unity: Our internal api for querying production data. -- UnityQL: A GraphQL API for accessing production data. -- Shotgrid: Our production tracking system. -- RV: A review tool used for viewing and annotating video footage. - -6) Additional Rules: -- You are not allowed to make up any information. You must only use the information provided to you. -- You are not allowed to use any information that is not provided to you. -- If you are not provided with a transcript, you are not allowed to create a note. Return an empty string. -- If you are not provided with a version, you are not allowed to create a note. Return an empty string. -- Never output any other text then the notes. For example do not output your thought process or analysis of the note. Just output the note. -- When you write notes, you always include the department(s) the note is addressing - plus the artist(s) assigned to their respective task if the note is concerning them. -There may be times when elements inside of tasks are laid out. eg. Tattoos on an asset. Use your best judgement to pair the comment with the asset in the shot. -- Never introduce yourself in the note. Just get straight to the point and generate the note. -- Do not add soft requests to the note. for example, "Keep me up to date on...." or "Let me know if...". Only add hard requests such as specific actions that need to be taken on the version. For example, what to fix. -- Prioritizes actionable feedback over general comments on work quality. -- Do not add any analysis to the note. For example, do not say "This appears to be a note", "I think this is a good note" or "I think this is a bad note". Just output the note. -- Do not embellish any notes that have been taken. For example, if the existing note says "This is good", do not add any additional analysis that this not mentioned. - -7) Formatting: -- Always format the note in the following format: - | | \n - - -Example Note: -Layout | Shot 123 | John Doe, Jane Doe -The layout is good. We need to add a few more details to the environment. - -8) Acceptance Criteria: -- The note must be in the format of the example note provided. -- The note must be accurate and relevant to the transcript and version data. -- The note must be clear and concise. -- The note must be complete and not missing any information. -- The note must be free of any errors. -- The note must be free of any emojis. -- The note must be free of any analysis. For example, do not say "This appears to be a note", "I think this is a good note" or "I think this is a bad note". Just output the header and note. -- The note should follow the guidelines for formatting. -- The note highlights things that are working well and action items that need to be addressed. -- Use the transcript to help you understand the notes already taken. Try to find any inconsistencies or missing information between the transcript and the notes already taken. -- If the transcript is not provided, you are not allowed to create a note. Return an empty string. -- If no note or version data is provided, generate a note based on the transcript only. - - - -Now, here is the transcript and the version data: - - -Transcript: -{{{{ transcript }}}} - -Version Data: -{{{{ context }}}} - -Notes taken on this version already: -{{{{ notes }}}} - -""" diff --git a/backend/src/main.py b/backend/src/main.py index f972d24f..3e03dd91 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -53,15 +53,16 @@ Transcript, User, UserSettings, + UserSettingsResponse, UserSettingsUpdate, Version, ) from dna.models.entity import ENTITY_MODELS, EntityBase +from dna.note_prompt_config import get_default_note_prompt from dna.prodtrack_providers.prodtrack_provider_base import ( ProdtrackProviderBase, get_prodtrack_provider, ) -from dna.prompts.default_prompt import DEFAULT_PROMPT from dna.storage_providers.storage_provider_base import ( StorageProviderBase, get_storage_provider, @@ -1191,22 +1192,60 @@ async def delete_playlist_metadata( # ----------------------------------------------------------------------------- +def _user_settings_to_response(settings: UserSettings) -> UserSettingsResponse: + """Attach configured default note prompt for API clients (e.g. settings UI).""" + return UserSettingsResponse( + _id=settings.id, + user_email=settings.user_email, + note_prompt=settings.note_prompt, + default_note_prompt=get_default_note_prompt(), + regenerate_on_version_change=settings.regenerate_on_version_change, + regenerate_on_transcript_update=settings.regenerate_on_transcript_update, + updated_at=settings.updated_at, + created_at=settings.created_at, + ) + + +def _empty_user_settings_response(user_email: str) -> UserSettingsResponse: + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + default = get_default_note_prompt() + return UserSettingsResponse( + _id="", + user_email=user_email, + note_prompt="", + default_note_prompt=default, + regenerate_on_version_change=False, + regenerate_on_transcript_update=False, + updated_at=now, + created_at=now, + ) + + @app.get( "/users/{user_email}/settings", tags=["User Settings"], summary="Get user settings", - description="Retrieve settings for a user by their email address.", - response_model=Optional[UserSettings], + description=( + "Retrieve settings for a user. When the user has no saved document, " + "returns default toggles and the configured default note prompt in " + "`default_note_prompt`; `note_prompt` is empty until the user saves a custom prompt." + ), + response_model=UserSettingsResponse, ) async def get_user_settings( user_email: str, provider: StorageProviderDep, current_user: CurrentUserDep, -) -> Optional[UserSettings]: +) -> UserSettingsResponse: """Get user settings.""" if user_email != current_user: raise HTTPException(status_code=403, detail="Forbidden") - return await provider.get_user_settings(user_email) + stored = await provider.get_user_settings(user_email) + if stored is None: + return _empty_user_settings_response(user_email) + return _user_settings_to_response(stored) @app.put( @@ -1214,18 +1253,19 @@ async def get_user_settings( tags=["User Settings"], summary="Create or update user settings", description="Create or update settings for a user.", - response_model=UserSettings, + response_model=UserSettingsResponse, ) async def upsert_user_settings( user_email: str, data: UserSettingsUpdate, provider: StorageProviderDep, current_user: CurrentUserDep, -) -> UserSettings: +) -> UserSettingsResponse: """Create or update user settings.""" if user_email != current_user: raise HTTPException(status_code=403, detail="Forbidden") - return await provider.upsert_user_settings(user_email, data) + updated = await provider.upsert_user_settings(user_email, data) + return _user_settings_to_response(updated) @app.delete( @@ -1480,7 +1520,7 @@ async def generate_note( prompt = ( user_settings.note_prompt if user_settings and user_settings.note_prompt - else DEFAULT_PROMPT + else get_default_note_prompt() ) segments = await storage_provider.get_segments_for_version( diff --git a/backend/tests/test_note_prompt_config.py b/backend/tests/test_note_prompt_config.py new file mode 100644 index 00000000..9ec0bda7 --- /dev/null +++ b/backend/tests/test_note_prompt_config.py @@ -0,0 +1,116 @@ +"""Tests for default note prompt YAML loading.""" + +import os +from pathlib import Path + +import pytest + +from dna.note_prompt_config import ( + clear_default_note_prompt_cache, + default_note_prompt_config_path, + get_default_note_prompt, +) + + +@pytest.fixture(autouse=True) +def _clear_prompt_cache(): + clear_default_note_prompt_cache() + yield + clear_default_note_prompt_cache() + + +def test_get_default_note_prompt_loads_packaged_yaml(): + text = get_default_note_prompt() + assert "Purpose and Goals" in text + assert "{{ transcript }}" in text or "{{transcript}}" in text + + +def test_dna_default_note_prompt_path_override(tmp_path: Path): + custom = tmp_path / "custom.yaml" + custom.write_text( + "default_note_prompt:\n body: |\n Hello {{ transcript }}\n", + encoding="utf-8", + ) + old = os.environ.get("DNA_DEFAULT_NOTE_PROMPT_PATH") + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = str(custom) + clear_default_note_prompt_cache() + try: + assert get_default_note_prompt().strip() == "Hello {{ transcript }}" + finally: + if old is None: + os.environ.pop("DNA_DEFAULT_NOTE_PROMPT_PATH", None) + else: + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = old + clear_default_note_prompt_cache() + + +def test_default_path_is_under_dna_config(): + p = default_note_prompt_config_path() + assert p.name == "default_note_prompt.yaml" + + +def test_invalid_yaml_raises(tmp_path: Path): + bad = tmp_path / "bad.yaml" + bad.write_text("42\n", encoding="utf-8") + old = os.environ.get("DNA_DEFAULT_NOTE_PROMPT_PATH") + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = str(bad) + clear_default_note_prompt_cache() + try: + with pytest.raises(ValueError, match="not a mapping"): + get_default_note_prompt() + finally: + if old is None: + os.environ.pop("DNA_DEFAULT_NOTE_PROMPT_PATH", None) + else: + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = old + clear_default_note_prompt_cache() + + +def test_default_note_prompt_not_mapping_raises(tmp_path: Path): + bad = tmp_path / "notmap.yaml" + bad.write_text("default_note_prompt: not-a-mapping\n", encoding="utf-8") + old = os.environ.get("DNA_DEFAULT_NOTE_PROMPT_PATH") + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = str(bad) + clear_default_note_prompt_cache() + try: + with pytest.raises(ValueError, match="missing 'default_note_prompt'"): + get_default_note_prompt() + finally: + if old is None: + os.environ.pop("DNA_DEFAULT_NOTE_PROMPT_PATH", None) + else: + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = old + clear_default_note_prompt_cache() + + +def test_missing_body_raises(tmp_path: Path): + bad = tmp_path / "nobody.yaml" + bad.write_text("default_note_prompt: {}\n", encoding="utf-8") + old = os.environ.get("DNA_DEFAULT_NOTE_PROMPT_PATH") + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = str(bad) + clear_default_note_prompt_cache() + try: + with pytest.raises(ValueError, match="body"): + get_default_note_prompt() + finally: + if old is None: + os.environ.pop("DNA_DEFAULT_NOTE_PROMPT_PATH", None) + else: + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = old + clear_default_note_prompt_cache() + + +def test_missing_file_raises(tmp_path: Path): + missing = tmp_path / "nope.yaml" + old = os.environ.get("DNA_DEFAULT_NOTE_PROMPT_PATH") + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = str(missing) + clear_default_note_prompt_cache() + try: + with pytest.raises(FileNotFoundError): + get_default_note_prompt() + finally: + if old is None: + os.environ.pop("DNA_DEFAULT_NOTE_PROMPT_PATH", None) + else: + os.environ["DNA_DEFAULT_NOTE_PROMPT_PATH"] = old + clear_default_note_prompt_cache() diff --git a/backend/tests/test_user_settings.py b/backend/tests/test_user_settings.py index 57a5c96f..b1dcfb80 100644 --- a/backend/tests/test_user_settings.py +++ b/backend/tests/test_user_settings.py @@ -10,6 +10,7 @@ UserSettings, UserSettingsUpdate, ) +from dna.note_prompt_config import get_default_note_prompt class TestUserSettingsModels: @@ -96,6 +97,7 @@ def test_get_user_settings_returns_200(self, mock_storage_provider, auth_client) data = response.json() assert data["user_email"] == "test@example.com" assert data["note_prompt"] == "Custom prompt" + assert data["default_note_prompt"] == get_default_note_prompt() assert data["regenerate_on_version_change"] is True assert data["regenerate_on_transcript_update"] is False mock_storage_provider.get_user_settings.assert_called_once_with( @@ -104,8 +106,10 @@ def test_get_user_settings_returns_200(self, mock_storage_provider, auth_client) finally: app.dependency_overrides.clear() - def test_get_user_settings_returns_null(self, mock_storage_provider, auth_client): - """Test GET returns null when user has no settings.""" + def test_get_user_settings_no_document_returns_defaults( + self, mock_storage_provider, auth_client + ): + """Test GET returns defaults and configured default prompt when user has no doc.""" mock_storage_provider.get_user_settings.return_value = None app.dependency_overrides[get_storage_provider_cached] = ( @@ -115,7 +119,12 @@ def test_get_user_settings_returns_null(self, mock_storage_provider, auth_client try: response = auth_client.get("/users/test@example.com/settings") assert response.status_code == 200 - assert response.json() is None + data = response.json() + assert data["user_email"] == "test@example.com" + assert data["note_prompt"] == "" + assert data["default_note_prompt"] == get_default_note_prompt() + assert data["regenerate_on_version_change"] is False + assert data["regenerate_on_transcript_update"] is False finally: app.dependency_overrides.clear() @@ -148,6 +157,7 @@ def test_upsert_user_settings_returns_200(self, mock_storage_provider, auth_clie assert response.status_code == 200 data = response.json() assert data["note_prompt"] == "Updated prompt" + assert data["default_note_prompt"] == get_default_note_prompt() assert data["regenerate_on_version_change"] is True assert data["regenerate_on_transcript_update"] is True mock_storage_provider.upsert_user_settings.assert_called_once() @@ -183,10 +193,39 @@ def test_upsert_user_settings_partial_update( assert response.status_code == 200 data = response.json() assert data["regenerate_on_version_change"] is True + assert data["default_note_prompt"] == get_default_note_prompt() mock_storage_provider.upsert_user_settings.assert_called_once() finally: app.dependency_overrides.clear() + def test_get_user_settings_empty_saved_prompt_includes_default_field( + self, mock_storage_provider, auth_client + ): + """Stored user with empty note_prompt still receives default_note_prompt.""" + now = datetime.now(timezone.utc) + mock_storage_provider.get_user_settings.return_value = UserSettings( + _id="settings1", + user_email="test@example.com", + note_prompt="", + regenerate_on_version_change=False, + regenerate_on_transcript_update=False, + updated_at=now, + created_at=now, + ) + + app.dependency_overrides[get_storage_provider_cached] = ( + lambda: mock_storage_provider + ) + + try: + response = auth_client.get("/users/test@example.com/settings") + assert response.status_code == 200 + data = response.json() + assert data["note_prompt"] == "" + assert data["default_note_prompt"] == get_default_note_prompt() + finally: + app.dependency_overrides.clear() + def test_delete_user_settings_returns_true( self, mock_storage_provider, auth_client ): diff --git a/frontend/packages/app/src/components/SettingsModal.tsx b/frontend/packages/app/src/components/SettingsModal.tsx index ddf9af12..1b625579 100644 --- a/frontend/packages/app/src/components/SettingsModal.tsx +++ b/frontend/packages/app/src/components/SettingsModal.tsx @@ -517,7 +517,7 @@ export function SettingsModal({ const queryClient = useQueryClient(); - const { data: settings, isLoading } = useQuery({ + const { data: settings, isLoading } = useQuery({ queryKey: ['userSettings', userEmail], queryFn: () => apiHandler.getUserSettings({ userEmail }), enabled: !!userEmail, @@ -534,15 +534,14 @@ export function SettingsModal({ useEffect(() => { if (settings) { - setNotePrompt(settings.note_prompt); + const displayPrompt = + settings.note_prompt.trim() !== '' + ? settings.note_prompt + : settings.default_note_prompt; + setNotePrompt(displayPrompt); setRegenerateOnVersionChange(settings.regenerate_on_version_change); setRegenerateOnTranscriptUpdate(settings.regenerate_on_transcript_update); setIsDirty(false); - } else if (settings === null) { - setNotePrompt(''); - setRegenerateOnVersionChange(false); - setRegenerateOnTranscriptUpdate(false); - setIsDirty(false); } }, [settings]); @@ -565,14 +564,19 @@ export function SettingsModal({ }, []); const handleSave = useCallback(() => { + const defaultTrimmed = (settings?.default_note_prompt ?? '').trim(); + const currentTrimmed = notePrompt.trim(); + const persistAsDefault = + currentTrimmed === '' || currentTrimmed === defaultTrimmed; mutation.mutate({ - note_prompt: notePrompt, + note_prompt: persistAsDefault ? '' : notePrompt, regenerate_on_version_change: regenerateOnVersionChange, regenerate_on_transcript_update: regenerateOnTranscriptUpdate, }); }, [ mutation, notePrompt, + settings?.default_note_prompt, regenerateOnVersionChange, regenerateOnTranscriptUpdate, ]); diff --git a/frontend/packages/app/src/hooks/useAISuggestion.ts b/frontend/packages/app/src/hooks/useAISuggestion.ts index c732233c..5bcfdcb9 100644 --- a/frontend/packages/app/src/hooks/useAISuggestion.ts +++ b/frontend/packages/app/src/hooks/useAISuggestion.ts @@ -51,7 +51,7 @@ export function useAISuggestion({ } ); - const { data: userSettings } = useQuery({ + const { data: userSettings } = useQuery({ queryKey: ['userSettings', userEmail], queryFn: () => apiHandler.getUserSettings({ userEmail: userEmail! }), enabled: isEnabled, diff --git a/frontend/packages/app/src/hooks/useDraftNote.test.tsx b/frontend/packages/app/src/hooks/useDraftNote.test.tsx index c41cbcdd..004fd46d 100644 --- a/frontend/packages/app/src/hooks/useDraftNote.test.tsx +++ b/frontend/packages/app/src/hooks/useDraftNote.test.tsx @@ -108,6 +108,7 @@ describe('useDraftNote', () => { to: [], cc: [], links: [], + attachmentIds: [], versionStatus: 'pending', published: false, edited: false, @@ -142,6 +143,7 @@ describe('useDraftNote', () => { to: [], cc: [], links: [], + attachmentIds: [], versionStatus: '', published: false, edited: false, @@ -251,6 +253,7 @@ describe('useDraftNote', () => { to: [], cc: [], links: [], + attachmentIds: [], versionStatus: '', published: false, edited: false, diff --git a/frontend/packages/app/src/test/render.tsx b/frontend/packages/app/src/test/render.tsx index 2d6b446a..9e32dcb9 100644 --- a/frontend/packages/app/src/test/render.tsx +++ b/frontend/packages/app/src/test/render.tsx @@ -5,6 +5,7 @@ import { ThemeProvider } from 'styled-components'; import { Theme } from '@radix-ui/themes'; import { theme } from '../styles'; import { AuthProvider } from '../contexts/AuthContext'; +import { ThemeModeProvider } from '../contexts/ThemeContext'; interface WrapperProps { children: ReactNode; @@ -28,7 +29,9 @@ function AllTheProviders({ children }: WrapperProps) { - {children} + + {children} + diff --git a/frontend/packages/core/src/apiHandler.ts b/frontend/packages/core/src/apiHandler.ts index 5e26d59b..3e495c70 100644 --- a/frontend/packages/core/src/apiHandler.ts +++ b/frontend/packages/core/src/apiHandler.ts @@ -229,10 +229,8 @@ class ApiHandler { ); } - async getUserSettings( - params: GetUserSettingsParams - ): Promise { - return this.get( + async getUserSettings(params: GetUserSettingsParams): Promise { + return this.get( `/users/${encodeURIComponent(params.userEmail)}/settings` ); } diff --git a/frontend/packages/core/src/interfaces.ts b/frontend/packages/core/src/interfaces.ts index a50ef99a..b539c1d5 100644 --- a/frontend/packages/core/src/interfaces.ts +++ b/frontend/packages/core/src/interfaces.ts @@ -328,7 +328,10 @@ export interface GetSegmentsParams { export interface UserSettings { _id: string; user_email: string; + /** Saved custom prompt; empty means use deployment default. */ note_prompt: string; + /** Configured default prompt template (for display when note_prompt is empty). */ + default_note_prompt: string; regenerate_on_version_change: boolean; regenerate_on_transcript_update: boolean; updated_at: string; From 3fb63aaca8aaff03dcfe3be6fa12eaff782a5d21 Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Mon, 27 Apr 2026 13:28:53 -0700 Subject: [PATCH 2/4] fix(frontend): improve bold/italic distinction in notes Markdown editor (#2) TipTap prose used font-weight 600 for strong (same as headings) with no em styles. Use 700 + explicit primary color for bold, secondary color for italic, and combined rules for bold+italic so emphasis reads clearly in light and dark themes. Fixes AcademySoftwareFoundation/dna#128 Co-authored-by: Cursor Agent Signed-off-by: James Spadafora Made-with: Cursor --- .../app/src/components/MarkdownEditor.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/frontend/packages/app/src/components/MarkdownEditor.tsx b/frontend/packages/app/src/components/MarkdownEditor.tsx index 9e32eb88..7f8c2e76 100644 --- a/frontend/packages/app/src/components/MarkdownEditor.tsx +++ b/frontend/packages/app/src/components/MarkdownEditor.tsx @@ -277,7 +277,20 @@ const EditorContent_ = styled(EditorContent)` } strong { - font-weight: 600; + font-weight: 700; + color: ${({ theme }) => theme.colors.text.primary}; + } + + em { + font-style: italic; + color: ${({ theme }) => theme.colors.text.secondary}; + } + + strong em, + em strong { + font-weight: 700; + font-style: italic; + color: ${({ theme }) => theme.colors.text.primary}; } code { From ff58abeb937d3740d8c7f3350a017fffd90ad614 Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Mon, 27 Apr 2026 14:25:19 -0700 Subject: [PATCH 3/4] Add a loading indicator to prevent a race condition created by regenerating while saving the prompt Signed-off-by: James Spadafora --- .../app/src/components/AssistantPanel.tsx | 4 +++- .../app/src/components/SettingsModal.tsx | 1 + .../packages/app/src/hooks/useAISuggestion.ts | 20 +++++++++++++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/packages/app/src/components/AssistantPanel.tsx b/frontend/packages/app/src/components/AssistantPanel.tsx index 4cd695df..5c4f0a1f 100644 --- a/frontend/packages/app/src/components/AssistantPanel.tsx +++ b/frontend/packages/app/src/components/AssistantPanel.tsx @@ -86,7 +86,9 @@ export function AssistantPanel({ }, [regenerate]); useHotkeyAction('aiInsert', handleAiInsert, { enabled: !!suggestion }); - useHotkeyAction('aiRegenerate', handleAiRegenerate); + useHotkeyAction('aiRegenerate', handleAiRegenerate, { + enabled: !isLoading, + }); return ( diff --git a/frontend/packages/app/src/components/SettingsModal.tsx b/frontend/packages/app/src/components/SettingsModal.tsx index 1b625579..db596e98 100644 --- a/frontend/packages/app/src/components/SettingsModal.tsx +++ b/frontend/packages/app/src/components/SettingsModal.tsx @@ -524,6 +524,7 @@ export function SettingsModal({ }); const mutation = useMutation({ + mutationKey: ['upsertUserSettings', userEmail], mutationFn: (data: UserSettingsUpdate) => apiHandler.upsertUserSettings({ userEmail, data }), onSuccess: () => { diff --git a/frontend/packages/app/src/hooks/useAISuggestion.ts b/frontend/packages/app/src/hooks/useAISuggestion.ts index 5bcfdcb9..c6a7f38b 100644 --- a/frontend/packages/app/src/hooks/useAISuggestion.ts +++ b/frontend/packages/app/src/hooks/useAISuggestion.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useIsMutating } from '@tanstack/react-query'; import { AISuggestionManager, type AISuggestionState, @@ -58,6 +58,11 @@ export function useAISuggestion({ staleTime: 60000, }); + const settingsUpsertInflight = + useIsMutating({ + mutationKey: ['upsertUserSettings', userEmail ?? ''], + }) > 0 && userEmail != null; + const prevVersionRef = useRef(null); useEffect(() => { @@ -124,7 +129,7 @@ export function useAISuggestion({ const regenerate = useCallback( (additionalInstructions?: string) => { - if (!isEnabled) return; + if (!isEnabled || settingsUpsertInflight) return; managerInstance .generateSuggestion( @@ -137,7 +142,13 @@ export function useAISuggestion({ // Error is captured in state }); }, - [playlistId, versionId, userEmail, isEnabled] + [ + playlistId, + versionId, + userEmail, + isEnabled, + settingsUpsertInflight, + ] ); return useMemo( @@ -145,7 +156,7 @@ export function useAISuggestion({ suggestion: state.suggestion, prompt: state.prompt, context: state.context, - isLoading: state.isLoading, + isLoading: state.isLoading || settingsUpsertInflight, error: state.error, regenerate, }), @@ -154,6 +165,7 @@ export function useAISuggestion({ state.prompt, state.context, state.isLoading, + settingsUpsertInflight, state.error, regenerate, ] From 6bdc0479ec63832254c7f3bf8ea3aada80af82a0 Mon Sep 17 00:00:00 2001 From: James Spadafora Date: Mon, 27 Apr 2026 14:26:03 -0700 Subject: [PATCH 4/4] Ignore symlinks to node_modules folders Signed-off-by: James Spadafora --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 919f8c0e..556db5cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .venv __pycache__/ node_modules/ +node_modules dist/ .DS_Store .vscode/