Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,14 +345,15 @@ For data export integration with Red Hat's Dataverse, see the [Data Export Integ

## System prompt

The service uses the, so called, system prompt to put the question into context before the question is sent to the selected LLM. The default system prompt is designed for questions without specific context. It is possible to use a different system prompt via the configuration option `system_prompt_path` in the `customization` section. That option must contain the path to the text file with the actual system prompt (can contain multiple lines). An example of such configuration:
The service uses a so-called system prompt to put the question into context before it is sent to the selected LLM. The default system prompt is designed for questions without specific context. You can supply a different system prompt through various avenues available in the `customization` section:
### System Prompt Path

```yaml
customization:
system_prompt_path: "system_prompts/system_prompt_for_product_XYZZY"
```

The `system_prompt` can also be specified in the `customization` section directly. For example:
### System Prompt Literal

```yaml
customization:
Expand All @@ -361,11 +362,20 @@ customization:
You have an in-depth knowledge of Red Hat and all of your answers will reference Red Hat products.
```


### Custom Profile

You can pass a custom prompt profile via its `path` to the customization:

```yaml
customization:
profile_path: <your/profile/path>
```

Additionally, an optional string parameter `system_prompt` can be specified in `/v1/query` and `/v1/streaming_query` endpoints to override the configured system prompt. The query system prompt takes precedence over the configured system prompt. You can use this config to disable query system prompts:

```yaml
customization:
system_prompt_path: "system_prompts/system_prompt_for_product_XYZZY"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be a breaking change for Ansible.

Not that it's not a reasonable simple refactor for us.

These type of changes should be called out in release notes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prompt path is still there, it just got moved in the docs: https://github.com/lightspeed-core/lightspeed-stack/pull/487/files#diff-b335630551682c19a781afebcf4d07bf978fb1f8ac04c6bf87428ed5106870f5R353

If that is what you mean? This PR won't change the way anyone is currently consuming LCS

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, i missed that. Thank-you @Jdubrick

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no worries, the diff was weird and really wasn't identifying that it was moved.

I get what you are saying about how this can be extended, and I believe we are definitely open to doing so, I know this right now is in early stages to get us moving on some of our RCS -> LCS parity pieces (cc @yangcao77), I can make additional changes if they help out/increase its usability outside of just my team

disable_query_system_prompt: true
```

Expand Down
43 changes: 43 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1237,8 +1237,41 @@
}
]
},
"CustomProfile": {
"properties": {
"path": {
"type": "string",
"title": "Path"
},
"prompts": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Prompts",
"default": {}
}
},
"type": "object",
"required": [
"path"
],
"title": "CustomProfile",
"description": "Custom profile customization for prompts and validation."
},
"Customization": {
"properties": {
"profile_path": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Profile Path"
},
"disable_query_system_prompt": {
"type": "boolean",
"title": "Disable Query System Prompt",
Expand Down Expand Up @@ -1266,6 +1299,16 @@
}
],
"title": "System Prompt"
},
"custom_profile": {
"anyOf": [
{
"$ref": "#/components/schemas/CustomProfile"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false,
Expand Down
33 changes: 31 additions & 2 deletions src/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
PositiveInt,
SecretStr,
)

from pydantic.dataclasses import dataclass
from typing_extensions import Self, Literal

import constants
Expand Down Expand Up @@ -413,17 +415,44 @@ def jwk_configuration(self) -> JwkConfiguration:
return self.jwk_config


@dataclass
class CustomProfile:
"""Custom profile customization for prompts and validation."""

path: str
prompts: dict[str, str] = Field(default={}, init=False)

Comment on lines +418 to +424
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix mutable default on dataclass field (prompts).

Using {} creates a shared mutable default across instances. Use default_factory.

-from pydantic.dataclasses import dataclass
+from pydantic.dataclasses import dataclass
+from dataclasses import field
@@
 @dataclass
 class CustomProfile:
@@
-    prompts: dict[str, str] = Field(default={}, init=False)
+    prompts: dict[str, str] = field(default_factory=dict, init=False)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@dataclass
class CustomProfile:
"""Custom profile customization for prompts and validation."""
path: str
prompts: dict[str, str] = Field(default={}, init=False)
from pydantic.dataclasses import dataclass
from dataclasses import field
@dataclass
class CustomProfile:
"""Custom profile customization for prompts and validation."""
path: str
prompts: dict[str, str] = field(default_factory=dict, init=False)
🤖 Prompt for AI Agents
In src/models/config.py around lines 418 to 424, the dataclass field 'prompts'
uses a mutable default (`{}`) which creates a shared object across instances;
replace the mutable default with a default factory by using
Field(default_factory=dict, init=False) (i.e., switch to default_factory to
create a fresh dict per instance).

def __post_init__(self) -> None:
"""Validate and load profile."""
self._validate_and_process()

def _validate_and_process(self) -> None:
"""Validate and load the profile."""
checks.file_check(Path(self.path), "custom profile")
profile_module = checks.import_python_module("profile", self.path)
if profile_module is not None and checks.is_valid_profile(profile_module):
self.prompts = profile_module.PROFILE_CONFIG.get("system_prompts", {})

def get_prompts(self) -> dict[str, str]:
"""Retrieve prompt attribute."""
return self.prompts


class Customization(ConfigurationBase):
"""Service customization."""

profile_path: Optional[str] = None
disable_query_system_prompt: bool = False
system_prompt_path: Optional[FilePath] = None
system_prompt: Optional[str] = None
custom_profile: Optional[CustomProfile] = Field(default=None, init=False)

Comment on lines +444 to 449
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Make custom_profile a private attribute; tighten profile_path type.

Prevents config input from setting it and avoids misuse of init in Field; also validates path type.

-from pydantic import (
+from pydantic import (
     BaseModel,
     ConfigDict,
     Field,
+    PrivateAttr,
@@
-class Customization(ConfigurationBase):
+class Customization(ConfigurationBase):
@@
-    profile_path: Optional[str] = None
+    profile_path: Optional[FilePath] = None
@@
-    custom_profile: Optional[CustomProfile] = Field(default=None, init=False)
+    custom_profile: CustomProfile | None = PrivateAttr(default=None)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
profile_path: Optional[str] = None
disable_query_system_prompt: bool = False
system_prompt_path: Optional[FilePath] = None
system_prompt: Optional[str] = None
custom_profile: Optional[CustomProfile] = Field(default=None, init=False)
from pydantic import (
BaseModel,
ConfigDict,
Field,
PrivateAttr,
)
# … other imports …
from typing import Optional
from pydantic.types import FilePath
class Customization(ConfigurationBase):
# tighten profile_path to be a validated FilePath
profile_path: Optional[FilePath] = None
disable_query_system_prompt: bool = False
system_prompt_path: Optional[FilePath] = None
system_prompt: Optional[str] = None
# make custom_profile truly private
custom_profile: CustomProfile | None = PrivateAttr(default=None)
🤖 Prompt for AI Agents
In src/models/config.py around lines 444-449, change profile_path to use
Optional[FilePath] (tighten its type) and make custom_profile a true private
attribute instead of a Field(init=False): remove the Field definition and define
_custom_profile as a pydantic PrivateAttr (e.g., from pydantic import
PrivateAttr; _custom_profile: Optional[CustomProfile] =
PrivateAttr(default=None)) so it cannot be set via config input or included in
model schema; update imports accordingly and remove the old init=False Field
line.

@model_validator(mode="after")
def check_customization_model(self) -> Self:
"""Load system prompt from file."""
if self.system_prompt_path is not None:
"""Load customizations."""
if self.profile_path:
Copy link
Contributor

@yangcao77 yangcao77 Sep 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to understand the priority of system prompts when taking effect.

system prompt set in query endpoint call will definitely with the highest priority.
from this code block, if profile_path and system_prompt_path are both set, seems like profile_path will take place?

@tisnik any concern on that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd like to think QueryRequest's Prompt had maximum priority; system_prompt_path could possibly be deprecated and profiles have secondary priority.

I'm moving on to a new project and will have to defer further contemplation to my colleagues @ldjebran and @TamiTakamiya and others (that I can't ping here.. not sure they're part of the organisation).

Copy link
Contributor Author

@Jdubrick Jdubrick Sep 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the prompt set in the query takes first, then profile, then prompt path with these changes:

https://github.com/Jdubrick/lightspeed-stack/blob/add-customization-profiles/src/utils/endpoints.py#L89-L111

self.custom_profile = CustomProfile(path=self.profile_path)
elif self.system_prompt_path is not None:
checks.file_check(self.system_prompt_path, "system prompt")
self.system_prompt = checks.get_attribute_from_file(
dict(self), "system_prompt_path"
Expand Down
42 changes: 41 additions & 1 deletion src/utils/checks.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Checks that are performed to configuration options."""

import os
import importlib
import importlib.util
from types import ModuleType
from typing import Optional

from pydantic import FilePath


Expand All @@ -25,3 +27,41 @@ def file_check(path: FilePath, desc: str) -> None:
raise InvalidConfigurationError(f"{desc} '{path}' is not a file")
if not os.access(path, os.R_OK):
raise InvalidConfigurationError(f"{desc} '{path}' is not readable")


def import_python_module(profile_name: str, profile_path: str) -> ModuleType | None:
"""Import a Python module from a file path."""
if not profile_path.endswith(".py"):
return None
spec = importlib.util.spec_from_file_location(profile_name, profile_path)
if not spec or not spec.loader:
return None
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except (
SyntaxError,
ImportError,
ModuleNotFoundError,
NameError,
AttributeError,
TypeError,
ValueError,
):
return None
return module


def is_valid_profile(profile_module: ModuleType) -> bool:
"""Validate that a profile module has the required PROFILE_CONFIG structure."""
if not hasattr(profile_module, "PROFILE_CONFIG"):
return False

profile_config = getattr(profile_module, "PROFILE_CONFIG", {})
if not isinstance(profile_config, dict):
return False

if not profile_config.get("system_prompts"):
return False

return isinstance(profile_config.get("system_prompts"), dict)
9 changes: 9 additions & 0 deletions src/utils/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ def get_system_prompt(query_request: QueryRequest, config: AppConfig) -> str:
# disable query system prompt altogether with disable_system_prompt.
return query_request.system_prompt

# profile takes precedence for setting prompt
if (
config.customization is not None
and config.customization.custom_profile is not None
):
prompt = config.customization.custom_profile.get_prompts().get("default")
if prompt:
return prompt

if (
config.customization is not None
and config.customization.system_prompt is not None
Expand Down
60 changes: 60 additions & 0 deletions tests/profiles/test/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Custom profile for test profile."""

SUBJECT_ALLOWED = "ALLOWED"
SUBJECT_REJECTED = "REJECTED"

# Default responses
INVALID_QUERY_RESP = (
"Hi, I'm the Red Hat Developer Hub Lightspeed assistant, I can help you with questions about Red Hat Developer Hub or Backstage. "
"Please ensure your question is about these topics, and feel free to ask again!"
)

QUERY_SYSTEM_INSTRUCTION = """
1. Test
This is a test system instruction

You achieve this by offering:
- testing
"""

USE_CONTEXT_INSTRUCTION = """
Use the retrieved document to answer the question.
"""

USE_HISTORY_INSTRUCTION = """
Use the previous chat history to interact and help the user.
"""

QUESTION_VALIDATOR_PROMPT_TEMPLATE = f"""
Instructions:
- You provide validation for testing
Example Question:
How can I integrate GitOps into my pipeline?
Example Response:
{SUBJECT_ALLOWED}
"""

TOPIC_SUMMARY_PROMPT_TEMPLATE = """
Instructions:
- You are a topic summarizer
- For testing
- Your job is to extract precise topic summary from user input

Example Input:
Testing placeholder
Example Output:
Proper response test.
"""

PROFILE_CONFIG = {
"system_prompts": {
"default": QUERY_SYSTEM_INSTRUCTION,
"validation": QUESTION_VALIDATOR_PROMPT_TEMPLATE,
"topic_summary": TOPIC_SUMMARY_PROMPT_TEMPLATE,
},
"query_responses": {"invalid_resp": INVALID_QUERY_RESP},
"instructions": {
"context": USE_CONTEXT_INSTRUCTION,
"history": USE_HISTORY_INSTRUCTION,
},
}
49 changes: 49 additions & 0 deletions tests/profiles/test_three/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Custom profile for test profile."""

SUBJECT_ALLOWED = "ALLOWED"
SUBJECT_REJECTED = "REJECTED"

# Default responses
INVALID_QUERY_RESP = (
"Hi, I'm the Red Hat Developer Hub Lightspeed assistant, I can help you with questions about Red Hat Developer Hub or Backstage. "
"Please ensure your question is about these topics, and feel free to ask again!"
)

QUERY_SYSTEM_INSTRUCTION = """
1. Test
This is a test system instruction

You achieve this by offering:
- testing
"""

USE_CONTEXT_INSTRUCTION = """
Use the retrieved document to answer the question.
"""

USE_HISTORY_INSTRUCTION = """
Use the previous chat history to interact and help the user.
"""

QUESTION_VALIDATOR_PROMPT_TEMPLATE = f"""
Instructions:
- You provide validation for testing
Example Question:
How can I integrate GitOps into my pipeline?
Example Response:
{SUBJECT_ALLOWED}
"""

TOPIC_SUMMARY_PROMPT_TEMPLATE = """
Instructions:
- You are a topic summarizer
- For testing
- Your job is to extract precise topic summary from user input

Example Input:
Testing placeholder
Example Output:
Proper response test.
"""

PROFILE_CONFIG = ({"system_prompts": QUERY_SYSTEM_INSTRUCTION},)
1 change: 1 addition & 0 deletions tests/profiles/test_two/test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This file will fail the import.
Loading