Compare commits

...

1 Commits

Author SHA1 Message Date
Laurent Paoletti 40b2f77c55 wip 2026-02-02 15:50:23 +01:00
17 changed files with 368 additions and 46 deletions
@@ -0,0 +1,65 @@
"""Document parsers for RAG backends."""
import logging
from urllib.parse import urljoin
from django.conf import settings
import requests
from chat.agent_rag.document_converter.markitdown import DocumentConverter
logger = logging.getLogger(__name__)
class BaseParser:
"""Base class for document parsers."""
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
"""
Parse the document and prepare it for the search operation.
This method should handle the logic to convert the document
into a format suitable for storage.
Args:
name (str): The name of the document.
content_type (str): The MIME type of the document (e.g., "application/pdf").
content (bytes): The content of the document as a bytes stream.
Returns:
str: The document content in Markdown format.
"""
raise NotImplementedError("Must be implemented in subclass.")
class AlbertParser(BaseParser):
"""Document parser using Albert API for PDFs and DocumentConverter for other formats."""
endpoint = urljoin(settings.ALBERT_API_URL, "/v1/parse-beta")
def parse_pdf_document(self, name: str, content_type: str, content: bytes) -> str:
"""Parse PDF document using Albert API."""
response = requests.post(
self.endpoint,
headers={
"Authorization": f"Bearer {settings.ALBERT_API_KEY}",
},
files={
"file": (name, content, content_type),
"output_format": (None, "markdown"),
},
timeout=settings.ALBERT_API_PARSE_TIMEOUT,
)
response.raise_for_status()
return "\n\n".join(
document_page["content"] for document_page in response.json().get("data", [])
)
def parse_document(self, name: str, content_type: str, content: bytes) -> str:
"""Parse document based on content type."""
if content_type == "application/pdf":
return self.parse_pdf_document(name=name, content_type=content_type, content=content)
return DocumentConverter().convert_raw(
name=name, content_type=content_type, content=content
)
+1
View File
@@ -353,6 +353,7 @@ class Base(BraveSettings, Configuration):
# Third party apps
"corsheaders",
"django_filters",
"solo.apps.SoloAppConfig",
"dockerflow.django",
"rest_framework",
"parler",
+24 -1
View File
@@ -3,7 +3,7 @@
from django.contrib import admin
from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from solo.admin import SingletonModelAdmin
from . import models
@@ -107,3 +107,26 @@ class UserAdmin(auth_admin.UserAdmin):
"allow_conversation_analytics",
)
list_editable = ("allow_conversation_analytics",)
@admin.register(models.SiteConfiguration)
class SiteConfigurationAdmin(SingletonModelAdmin):
fieldsets = (
(_("Environment Banner"), {
"description": _("Inform users about the current environment (staging, demo...)"),
"fields": (
"environment_banner_level",
"environment_banner_title",
"environment_banner_content",
),
}),
(_("Status / Incident Banner"), {
"description": _("Communicate maintenance windows or ongoing incidents"),
"fields": (
"status_banner_level",
"status_banner_title",
"status_banner_content",
"status_banner_dismissible",
),
}),
)
+29
View File
@@ -225,6 +225,8 @@ class ConfigView(drf.views.APIView):
dict_settings["chat_upload_accept"] = ",".join(settings.RAG_FILES_ACCEPTED_FORMATS)
dict_settings["banners"] = self._get_banners()
return drf.response.Response(dict_settings)
def _load_theme_customization(self):
@@ -257,3 +259,30 @@ class ConfigView(drf.views.APIView):
)
return theme_customization
def _get_banners(self):
"""Return active banners from SiteConfiguration."""
config = models.SiteConfiguration.get_solo()
banners = {}
if config.environment_banner_enabled :
banners["environment"] = {
"type": "environment",
"level": config.environment_banner_level,
"title": config.environment_banner_title,
"content": config.environment_banner_content,
"dismissible": False,
}
if config.status_banner_enabled :
banners["status"] = {
"type": "status",
"level": config.status_banner_level,
"title": config.status_banner_title,
"content": config.status_banner_content,
"dismissible": config.status_banner_dismissible,
}
return banners
@@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2026-01-29 10:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_user_short_name'),
]
operations = [
migrations.CreateModel(
name='SiteConfiguration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('environment_banner_title', models.CharField(blank=True, verbose_name='Environment banner - title')),
('environment_banner_content', models.TextField(blank=True, verbose_name='Environment banner - content')),
('environment_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Environment banner - level')),
('status_banner_title', models.CharField(blank=True, verbose_name='Status banner - title')),
('status_banner_content', models.TextField(blank=True, verbose_name='Status banner - content')),
('status_banner_level', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('alert', 'Alert')], default='info', max_length=20, verbose_name='Status banner - level')),
('status_banner_dismissible', models.BooleanField(default=True, help_text='Allow users to dismiss this banner', verbose_name='Status banner - dismissible')),
],
options={
'verbose_name': 'Site Configuration',
},
),
]
+3
View File
@@ -0,0 +1,3 @@
from .base import BaseModel
from .users import User, DuplicateEmailError,UserManager
from .app_config import SiteConfiguration
+64
View File
@@ -0,0 +1,64 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from solo.models import SingletonModel
class BannerLevelChoice(models.TextChoices):
INFO = "info", _("Info")
WARNING = "warning", _("Warning")
ALERT = "alert", _("Alert")
class SiteConfiguration(SingletonModel):
"""Site-wide configuration for banners and announcements."""
# Environment banner (staging, demo, QA, sandbox, etc.)
environment_banner_title = models.CharField(
blank=True,
verbose_name=_("Environment banner - title"),
)
environment_banner_content = models.TextField(
blank=True,
verbose_name=_("Environment banner - content"),
)
environment_banner_level = models.CharField(
verbose_name=_("Environment banner - level"),
max_length=20,
choices=BannerLevelChoice.choices,
default=BannerLevelChoice.INFO,
)
# Status/incident banner
status_banner_title = models.CharField(
blank=True,
verbose_name=_("Status banner - title"),
)
status_banner_content = models.TextField(
blank=True,
verbose_name=_("Status banner - content"),
)
status_banner_level = models.CharField(
verbose_name=_("Status banner - level"),
max_length=20,
choices=BannerLevelChoice.choices,
default=BannerLevelChoice.INFO,
)
status_banner_dismissible = models.BooleanField(
default=True,
verbose_name=_("Status banner - dismissible"),
help_text=_("Allow users to dismiss this banner"),
)
def __str__(self):
return "Site Configuration"
@property
def environment_banner_enabled(self):
return bool(self.environment_banner_title or self.environment_banner_content)
@property
def status_banner_enabled(self):
return bool(self.status_banner_title or self.status_banner_content)
class Meta:
verbose_name = _("Site Configuration" )
+43
View File
@@ -0,0 +1,43 @@
"""
Declare and configure base models for the conversations core application
"""
from django.db import models
import uuid
from django.utils.translation import gettext_lazy as _
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta: # pylint:disable=missing-class-docstring
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
@@ -1,8 +1,8 @@
"""
Declare and configure the models for the conversations core application
Declare and configure the user models for the conversations core application
"""
import uuid
from logging import getLogger
from django.conf import settings
@@ -14,6 +14,8 @@ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .base import BaseModel
logger = getLogger(__name__)
@@ -27,42 +29,6 @@ class DuplicateEmailError(Exception):
super().__init__(self.message)
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta: # pylint:disable=missing-class-docstring
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
class UserManager(auth_models.UserManager):
"""Custom manager for User model with additional methods."""
+1
View File
@@ -67,6 +67,7 @@ dependencies = [
"trafilatura==2.0.0",
"uvicorn==0.38.0",
"whitenoise==6.11.0",
"django-solo>=2.5.1",
]
[project.urls]
+14
View File
@@ -407,6 +407,7 @@ dependencies = [
{ name = "django-parler" },
{ name = "django-pydantic-field" },
{ name = "django-redis" },
{ name = "django-solo" },
{ name = "django-storages", extra = ["s3"] },
{ name = "django-timezone-field" },
{ name = "djangorestframework" },
@@ -480,6 +481,7 @@ requires-dist = [
{ name = "django-parler", specifier = "==2.3" },
{ name = "django-pydantic-field", specifier = "==0.4.0" },
{ name = "django-redis", specifier = "==6.0.0" },
{ name = "django-solo", specifier = ">=2.5.1" },
{ name = "django-storages", extras = ["s3"], specifier = "==1.14.6" },
{ name = "django-test-migrations", marker = "extra == 'dev'", specifier = "==1.5.0" },
{ name = "django-timezone-field", specifier = ">=5.1" },
@@ -831,6 +833,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/79/055dfcc508cfe9f439d9f453741188d633efa9eab90fc78a67b0ab50b137/django_redis-6.0.0-py3-none-any.whl", hash = "sha256:20bf0063a8abee567eb5f77f375143c32810c8700c0674ced34737f8de4e36c0", size = 33687, upload-time = "2025-06-17T18:15:34.165Z" },
]
[[package]]
name = "django-solo"
version = "2.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/b3/1128d46ca1956dca02b7425bd5bc753c3a31ed21be6c3bb19b8d4c5316c9/django_solo-2.5.1.tar.gz", hash = "sha256:ca3b2e44dbb4726446941ca8d765c247123f709f051d07e62eacf97330ffde01", size = 13662, upload-time = "2026-01-01T15:46:52.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/aa/45058bdec53ce3df577be9bd40c630067788923284becc6cca6fc592d4db/django_solo-2.5.1-py3-none-any.whl", hash = "sha256:23bfe48950587b409ec4b4ef95e5e62c1c6c590dd958d8d8b99d37d5ccb53ca8", size = 16882, upload-time = "2026-01-01T15:46:51.087Z" },
]
[[package]]
name = "django-storages"
version = "1.14.6"
@@ -41,7 +41,7 @@ export interface ConfigResponse {
const LOCAL_STORAGE_KEY = 'conversations_config';
function getCachedTranslation() {
function getCachedConfig() {
try {
const jsonString = localStorage.getItem(LOCAL_STORAGE_KEY);
return jsonString ? (JSON.parse(jsonString) as ConfigResponse) : undefined;
@@ -50,7 +50,7 @@ function getCachedTranslation() {
}
}
function setCachedTranslation(translations: ConfigResponse) {
function setCachedConfig(translations: ConfigResponse) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(translations));
}
@@ -62,7 +62,8 @@ export const getConfig = async (): Promise<ConfigResponse> => {
}
const config = response.json() as Promise<ConfigResponse>;
setCachedTranslation(await config);
setCachedConfig(await config);
return config;
};
@@ -70,7 +71,7 @@ export const getConfig = async (): Promise<ConfigResponse> => {
export const KEY_CONFIG = 'config';
export function useConfig() {
const cachedData = getCachedTranslation();
const cachedData = getCachedConfig();
const oneHour = 1000 * 60 * 60;
return useQuery<ConfigResponse, APIError, ConfigResponse>({
@@ -137,6 +137,7 @@ export const InputChat = ({
const { isDesktop, isMobile } = useResponsiveStore();
const { data: conf } = useConfig();
const { isFeatureFlagActivated } = useAnalytics();
const [fileUploadEnabled, setFileUploadEnabled] = useState(false);
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
@@ -0,0 +1,28 @@
// hooks/useElementHeight.ts
import { useState, useEffect, useRef, RefObject } from 'react';
export function useElementHeight<T extends HTMLElement>(): [
RefObject<T>,
number,
] {
const ref = useRef<T>(null);
const [height, setHeight] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setHeight(entry.contentRect.height);
}
});
observer.observe(element);
setHeight(element.offsetHeight); // initial measurement
return () => observer.disconnect();
}, []);
return [ref, height];
}
@@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react';
import { PropsWithChildren, useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
@@ -8,11 +8,37 @@ import { Header } from '@/features/header';
import { LeftPanel } from '@/features/left-panel';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useResponsiveStore } from '@/stores';
import { useElementHeight } from '@/hook/useElementHeight';
import { FeatureFlagState, useConfig } from '@/core';
type MainLayoutProps = {
backgroundColor?: 'white' | 'grey';
};
const Banner = ({
title = '',
content = '',
}: {
title?: string;
content?: string;
}) => {
if (!content) {
return null;
}
return (
<Box
$css={css`
background-color: #ccc;
display: flex;
align-items: center;
`}
>
<strong>{title}</strong>
<p>{content}</p>
</Box>
);
};
export function MainLayout({
children,
backgroundColor: _backgroundColor = 'white',
@@ -21,15 +47,41 @@ export function MainLayout({
const { colorsTokens } = useCunninghamTheme();
const { isPanelOpen } = useChatPreferencesStore();
const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`;
const [banners, setBanners] = useState({});
const { data: conf } = useConfig();
const [bannerRef, bannerHeight] = useElementHeight<HTMLDivElement>();
useEffect(() => {
setBanners(conf?.banners ?? {});
}, [conf]);
const HEADER_HEIGHT = `${isDesktop ? '65' : '52'}`;
console.log(banners);
console.log(bannerHeight);
return (
<Box className="--docs--main-layout">
<Box
ref={bannerRef}
$css={css`
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1001;
background-color: #ccc;
`}
>
<Banner content={banners?.environment?.content}></Banner>
<Banner content={banners?.status?.content}></Banner>
</Box>
<Box
$css={css`
z-index: 1000;
transition: left 0.3s ease;
position: fixed;
top: ${bannerHeight}px;
width: 300px;
left: ${isPanelOpen ? '0px' : '-300px'};
`}
@@ -40,6 +92,7 @@ export function MainLayout({
$css={css`
transition: all 0.3s ease;
position: fixed;
top: ${bannerHeight}px;
left: ${isDesktop && isPanelOpen ? '300px' : '0px'};
width: calc(100vw - ${isDesktop && isPanelOpen ? '300px' : '0px'});
`}
@@ -52,6 +52,7 @@ export const overrideConfig = async (
) =>
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
console.log("CONFIG")
if (request.method().includes('GET')) {
await route.fulfill({
json: {
+1 -1
View File
@@ -399,7 +399,7 @@ glob-parent@~5.1.2:
dependencies:
is-glob "^4.0.1"
glob@^10.5.0:
glob@^10.3.10, glob@^10.3.3:
version "10.5.0"
resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c"
integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==