Compare commits

..

23 Commits

Author SHA1 Message Date
renovate[bot] 51a735972e ⬆️(dependencies) update python dependencies 2026-03-23 01:04:03 +00:00
Charles Englebert 5189db1b2b ♻️(backend) reorganize evaluation data (#59)
I am improving the data management of evaluation system
2026-03-16 09:58:15 +01:00
Charles Englebert 0b8bdb08f7 handle search types (#51)
* (backend) tests

I am adding beautiful tests

* (backend) add search_type params

We want to control the type of search to use at run time
to feature flag Docs users with full-test or with hybrid
search. I am adding a search_type params on the search
route.

* 🚨(backend) changelog

chaaaaaaaaaangelooooooooooog

* ♻️(backend) refactor

i compute the actual search_type in the view
and make the variable mandatory in the service.
2026-03-10 13:48:54 +01:00
renovate[bot] 437fc0f049 ⬆️(dependencies) update django to v5.2.12 [SECURITY] 2026-03-10 13:39:31 +01:00
Stephan Meijer 71744cc264 ⬆️(ci) upgrade GitHub Actions workflow steps to latest versions
Update all GitHub Actions to their latest major versions for improved
performance, security patches, and Node.js runtime compatibility.

Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-02-27 13:26:44 +01:00
Quentin BEY 195722b988 🔥(github) drop unused deploy workflow
We don't use it, and production should never be deployed this way.
2026-02-27 13:19:24 +01:00
renovate[bot] 35e308f26d ⬆️(dependencies) update django to v5.2.11 [SECURITY] 2026-02-27 13:08:12 +01:00
Stephan Meijer d676516e86 👷(docker) add arm64 platform support for image builds
Signed-off-by: Stephan Meijer <me@stephanmeijer.com>
2026-02-19 22:42:48 +01:00
Charles Englebert 2c090551c0 Filter on path (#53)
* (backend) add path filter

allow search filters on path for Docs compatibility

* (backend) test

I improve existing and add new tests
2026-02-09 16:45:18 +01:00
Charles Englebert 033bd42bc4 Revert "(backend) add path filter (#48)" (#52)
This reverts commit 28a3a10c05.
2026-02-09 16:38:05 +01:00
Charles Englebert 28a3a10c05 (backend) add path filter (#48)
* (backend) add path filter

allow search filters on path for Docs compatibility

* (backend) test

I improve existing and add new tests

* (backend) add search_type params

We want to control the type of search to use at run time
to feature flag Docs users with full-test or with hybrid
search.

I am adding a search_type params on the search route.

* 🚨(backend) linters

fiiiiiiiiiix liiiiiinters
2026-02-09 16:18:32 +01:00
Charles Englebert 05aebf564a (backend) allow delete documents by tags (#45)
* (backend) allow delete documents by tags

the assistant does not know the ids. It needs to be able to detele
documents by tags.

* ♻️(backend) refactor

I refactor to have a better code.

* 🚨(backend) various fixes

I am fixing things for review.
2026-01-20 10:36:03 +01:00
Quentin BEY f8b87cc1c2 👷(opensearch) wait for service to be up before tests
Now we are using `uv` the tests start while the service is
not yet ready... We need to check the service readiness.
2026-01-18 23:22:14 +01:00
Quentin BEY b76dd37d76 🔧(backend) ignore .venv in compile messages command
We have to change the ignored files used in the compoilemessages
command. uv is using .venv directory and not venv
2026-01-18 22:57:58 +01:00
Quentin BEY c4ffcbea84 🔥(backend) remove setup.py
uv is supporting PEP 517, setup.py file calling setuptools is not needed
anymore.
2026-01-18 22:57:58 +01:00
Quentin BEY ce8869af2f 🔧(actions) migrate from pip to uv
Migreate usage of pip to uv in github actions. How python is setup is
also changed. Doing like this, we will just have to upgrade the python
version requirement in the pyproject file
2026-01-18 22:57:58 +01:00
Quentin BEY b72779aed2 🏗️(core) migrate from pip to uv
We want to migrate our projects from pip to uv to take the benefits of
the lock file and have reproducible installations.
A first uv.lock file is comitted and the Dockerfile and compose are
modified to work with uv
2026-01-18 22:57:58 +01:00
Charles Englebert b0a14c4c37 (backend) add deletion endpoint (#31)
* (backend) add tags field for result filtering (#29)

* 🚨(backend) remove dead files

I left files I should have removed

* (backend) add tags field

I add a tag field in the index and a filtering.
A tag is keyword that can be applied to a document.
A document can have several tags.
Tags allow filtering related documents.
The Conversations app needs tags for its rag tool.

* (backend) enhance document indexing with error handling and changelog

I enhance document indexing with error handling and changelog

* 📝(backend) improve docstrings and Changelog

Docstrings and Changelog had to be improved.
This commit improves them.

* 🐛(backend) prevent information lick

return user message with less info and log error

* 🚨(backend) return undeleted document IDs

We need better responses in case of unexpected behaviour.
I enhance document deletion API by returning undeleted
document IDs
2026-01-15 17:21:42 +01:00
Charles Englebert 1822ee407a Adapt to conversation (#30)
* (backend) enhance document indexing with error handling and changelog

I enhance document indexing with error handling and changelog

* (backend) adapt to conversation RAG

the document rag tool of conversation expect a content
and a dedicated service with token access is needed.
I am also adding loggers.

* 🚨(backend) fix tests

demo tests are broken. Here is the fix.
2026-01-15 15:17:34 +01:00
Quentin BEY 69374eb789 🎨(pylint) fix issues after update and __init__ add
Fix the pylint errors after the two previous commits.
2026-01-14 14:55:05 +01:00
Quentin BEY bdd7cce492 🐛(global) add missing __init__ files
Module init files where missing in several places. This
prevented pylint to analyse files.
2026-01-14 13:42:50 +01:00
renovate[bot] b813e6d6c2 ⬆️(dependencies) update python dependencies 2026-01-14 13:42:23 +01:00
Quentin BEY a3b090216c 🔧(settings) update production configuration for HSTS
This is based on Docs project settings.
2026-01-13 23:56:22 +01:00
123 changed files with 3972 additions and 500 deletions
-52
View File
@@ -1,52 +0,0 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "drive,secrets"
-
name: Checkout repository
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/drive/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+8 -1
View File
@@ -20,7 +20,13 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
-
name: Set up QEMU
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
-
name: Docker meta
id: meta
@@ -44,6 +50,7 @@ jobs:
with:
context: .
target: backend-production
platforms: linux/amd64,linux/arm64
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+42 -19
View File
@@ -14,7 +14,7 @@ jobs:
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: show
@@ -37,7 +37,7 @@ jobs:
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v6
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
@@ -47,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v6
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
@@ -63,19 +63,22 @@ jobs:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v3
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install development dependencies
run: pip install --user .[dev]
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
run: uv run pylint .
test-back:
runs-on: ubuntu-latest
@@ -105,6 +108,11 @@ jobs:
OPENSEARCH_INITIAL_ADMIN_PASSWORD: find.PASS123
ports:
- 9200:9200
options: >-
--health-cmd "curl -s http://localhost:9200 > /dev/null || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
DJANGO_CONFIGURATION: Test
@@ -123,20 +131,35 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
- name: Install Python
uses: actions/setup-python@v3
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
python-version-file: "src/backend/pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install development dependencies
run: pip install --user .[dev]
- name: Wait for OpenSearch to be ready
run: |
for i in {1..60}; do
if curl -s http://localhost:9200 > /dev/null; then
echo "OpenSearch is ready"
exit 0
fi
echo "Waiting for OpenSearch... attempt $i"
sleep 1
done
echo "OpenSearch failed to start"
exit 1
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Run tests
run: ~/.local/bin/pytest
run: uv run pytest
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
+12 -3
View File
@@ -10,6 +10,7 @@ and this project adheres to
## Added
- 👷(docker) add arm64 platform support for image builds
- ✨(backend) add semantic search
- ✨(backend) add multi-embedding and chunking
- ✨(backend) add analyzers to full-text search
@@ -20,14 +21,22 @@ and this project adheres to
- ✨(backend) allow indexation of documents with either empty content or title.
- ✨(api) new fulltext 'search/' view with OIDC resource server authentication
- ✨(backend) limit access to documents : public & authenticated with a
linkreach & owned ones
- ✨(backend) limit search to the calling app (audience) and a configured
list of services
- 🔧(compose) rename docker network 'lasuite-net' as 'lasuite-network'
- ✨(backend) add demo service for Drive.
- ✨(backend) Add OPENSEARCH_INDEX_PREFIX setting to prevent naming overlaping
- ✨(backend) add OPENSEARCH_INDEX_PREFIX setting to prevent naming overlaping
issues if the opensearch database is shared between apps.
- ✨(backend) add tags
- ✨(backend) adapt to conversation RAG
- ✨(backend) add deletion endpoint
- ✨(backend) add path filter
- ✨(backend) add search_type param
## Changed
- 🏗️(backend) switch Python dependency management to uv
- ✨(backend) allow deletion by tags
- ♻️(backend) improve the evaluation command
## Fixed
+30 -21
View File
@@ -3,9 +3,6 @@
# ---- base image to inherit from ----
FROM python:3.12-slim-bookworm AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apt-get update && \
apt-get -y upgrade && \
@@ -14,13 +11,28 @@ RUN apt-get update && \
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
# Copy required python dependencies
COPY ./src/backend /builder
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
RUN mkdir /install && \
pip install --prefix=/install .
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.9.10 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY ./src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- static link collector ----
FROM base AS link-collector
@@ -33,11 +45,10 @@ RUN apt-get update && \
rdfind && \
rm -rf /var/lib/apt/lists/*
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy the application from the builder
COPY --from=back-builder /app /app
# Copy find application (see .dockerignore)
COPY ./src/backend /app/
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
@@ -76,14 +87,13 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy find application (see .dockerignore)
COPY ./src/backend /app/
# Copy the prepared application (see .dockerignore)
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
@@ -100,10 +110,9 @@ RUN apt-get update && \
apt-get install -y postgresql-client && \
rm -rf /var/lib/apt/lists/*
# Uninstall find and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y find
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.10,source=/uv,target=/bin/uv \
uv sync --locked --all-extras
# Restore the un-privileged user running the application
ARG DOCKER_USER
+1 -1
View File
@@ -173,7 +173,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
+2
View File
@@ -70,6 +70,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
postgresql:
condition: service_started
@@ -90,6 +91,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- app
+1 -5
View File
@@ -7,7 +7,7 @@ extension-pkg-whitelist=
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=migrations
ignore=migrations,.venv
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
@@ -31,10 +31,6 @@ persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
+12
View File
@@ -13,6 +13,16 @@ class ReachEnum(str, Enum):
RESTRICTED = "restricted"
# Search type
class SearchTypeEnum(str, Enum):
"""Search type options"""
HYBRID = "hybrid"
FULL_TEXT = "full_text"
# Fields
CREATED_AT = "created_at"
@@ -23,6 +33,7 @@ REACH = "reach"
SIZE = "size"
TAGS = "tags"
TITLE = "title"
CONTENT = "content"
UPDATED_AT = "updated_at"
USERS = "users"
GROUPS = "groups"
@@ -32,6 +43,7 @@ RELEVANCE = "relevance"
ORDER_BY_OPTIONS = (RELEVANCE, TITLE, CREATED_AT, UPDATED_AT, SIZE, REACH)
SOURCE_FIELDS = (
TITLE,
CONTENT,
SIZE,
DEPTH,
PATH,
@@ -57,7 +57,7 @@ def reindex_with_embedding(index_name, batch_size=500, scroll="10m"):
returns a dict with the number of successful embeddings and failed embeddings.
"""
opensearch_client_ = opensearch_client()
page = opensearch_client_.search(
page = opensearch_client_.search( # pylint: disable=unexpected-keyword-arg
index=index_name,
scroll=scroll,
size=batch_size,
@@ -130,7 +130,9 @@ def reindex_with_embedding(index_name, batch_size=500, scroll="10m"):
nb_failed_embedding += 1
opensearch_client_.bulk(index=index_name, body=actions)
page = opensearch_client_.scroll(scroll_id=page["_scroll_id"], scroll=scroll)
page = opensearch_client_.scroll( # pylint: disable=unexpected-keyword-arg
scroll_id=page["_scroll_id"], scroll=scroll
)
opensearch_client_.clear_scroll(scroll_id=page["_scroll_id"])
return {
+20
View File
@@ -18,6 +18,7 @@ from pydantic import (
)
from . import enums
from .enums import SearchTypeEnum
class DocumentSchema(BaseModel):
@@ -114,6 +115,25 @@ class SearchQueryParametersSchema(BaseModel):
visited: StringListParameter = Field(default_factory=list)
reach: Optional[enums.ReachEnum] = None
tags: StringListParameter = Field(default_factory=list)
path: Optional[str] = None
order_by: Optional[Literal[enums.ORDER_BY_OPTIONS]] = Field(default=enums.RELEVANCE)
order_direction: Optional[Literal["asc", "desc"]] = Field(default="desc")
nb_results: Optional[conint(ge=1, le=300)] = Field(default=50)
search_type: Optional[SearchTypeEnum] = Field(default=None)
class DeleteDocumentsSchema(BaseModel):
"""Schema for validating the delete documents request"""
service: str = Field(max_length=300)
document_ids: Optional[List[str]] = Field(default=None)
tags: Optional[List[str]] = Field(default=None)
@model_validator(mode="after")
def check_at_least_one_filter(self):
"""Ensure at least one of document_ids or tags is provided"""
if not self.document_ids and not self.tags:
raise ValueError(
"At least one of 'document_ids' or 'tags' must be provided"
)
return self
+25
View File
@@ -3,6 +3,7 @@
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from langchain_text_splitters import RecursiveCharacterTextSplitter
from opensearchpy.exceptions import NotFoundError
@@ -14,6 +15,7 @@ from core.services.opensearch_configuration import (
MAPPINGS,
)
from ..models import Service, get_opensearch_index_name
from .embedding import embed_text
from .opensearch import check_hybrid_search_enabled, opensearch_client
@@ -124,3 +126,26 @@ def detect_language_code(text):
return settings.UNDETERMINED_LANGUAGE_CODE
return detected_code
def get_opensearch_indices(audience, services):
"""
Get OpenSearch indices for the given audience and services.
"""
try:
user_service = Service.objects.get(client_id=audience, is_active=True)
except Service.DoesNotExist as e:
logger.warning("Login failed: No service %s found", audience)
raise SuspiciousOperation("Service is not available") from e
# Find allowed sub-services for this service
allowed_services = set(user_service.services.values_list("name", flat=True))
allowed_services.add(user_service.name)
if services:
available_service = set(services).intersection(allowed_services)
if len(available_service) < len(services):
raise SuspiciousOperation("Some requested services are not available")
return [get_opensearch_index_name(service) for service in allowed_services]
+42 -10
View File
@@ -5,6 +5,7 @@ import logging
from django.conf import settings
from core import enums
from core.enums import SearchTypeEnum
from .embedding import embed_text
from .opensearch import check_hybrid_search_enabled, opensearch_client
@@ -24,6 +25,8 @@ def search( # noqa : PLR0913
user_sub,
groups,
tags,
search_type,
path=None,
):
"""Perform an OpenSearch search"""
query = get_query(
@@ -34,6 +37,8 @@ def search( # noqa : PLR0913
user_sub=user_sub,
groups=groups,
tags=tags,
path=path,
search_type=search_type,
)
return opensearch_client().search( # pylint: disable=unexpected-keyword-arg
index=",".join(search_indices),
@@ -49,22 +54,29 @@ def search( # noqa : PLR0913
order_direction=order_direction,
),
"size": nb_results,
# Compute query
"query": query,
},
params=get_params(query_keys=query.keys()),
# disable=unexpected-keyword-arg because
# ignore_unavailable is not in the the method declaration
# ignore_unavailable is not in the method declaration
ignore_unavailable=True,
)
# pylint: disable=too-many-arguments, too-many-positional-arguments
def get_query( # noqa : PLR0913
q, nb_results, reach, visited, user_sub, groups, tags
q,
nb_results,
reach,
visited,
user_sub,
groups,
tags,
search_type,
path=None,
):
"""Build OpenSearch query body based on parameters"""
filter_ = get_filter(reach, visited, user_sub, groups, tags)
filter_ = get_filter(reach, visited, user_sub, groups, tags, path)
if q == "*":
logger.info("Performing match_all query")
@@ -75,11 +87,7 @@ def get_query( # noqa : PLR0913
},
}
hybrid_search_enabled = check_hybrid_search_enabled()
if hybrid_search_enabled:
q_vector = embed_text(q)
else:
q_vector = None
q_vector = vectorize_query(q, search_type)
if not q_vector:
logger.info("Performing full-text search without embedding: %s", q)
@@ -96,6 +104,22 @@ def get_query( # noqa : PLR0913
}
def vectorize_query(q, search_type):
"""Vectorize the query if hybrid search is enabled and requested"""
hybrid_search_enabled = check_hybrid_search_enabled()
if search_type == SearchTypeEnum.HYBRID:
if not hybrid_search_enabled:
logger.warning(
"Hybrid search was requested (search_type=hybrid) but is disabled on server",
)
return None
return embed_text(q)
return None
def get_semantic_search_query(q_vector, filter_, nb_results):
"""Build OpenSearch semantic search query"""
return {
@@ -155,7 +179,10 @@ def get_full_text_query(q, filter_):
}
def get_filter(reach, visited, user_sub, groups, tags):
# pylint: disable=too-many-arguments, too-many-positional-arguments
def get_filter( # noqa : PLR0913
reach, visited, user_sub, groups, tags, path=None
):
"""Build OpenSearch filter"""
filters = [
{"term": {"is_active": True}}, # filter out inactive documents
@@ -192,6 +219,11 @@ def get_filter(reach, visited, user_sub, groups, tags):
# logical or: if tags are provided the matching documents should have at least one of them
filters.append({"terms": {"tags": tags}})
# Optional path filter
if path:
# filter documents that start with the provided path
filters.append({"prefix": {"path": path}})
return filters
@@ -64,12 +64,12 @@ def test_reindex_with_embedding_command(settings):
prepare_index(index_name, documents)
# the index has not been embedded in the initial state
initial_index = opensearch_client_.search(
initial_index = opensearch_client_.search( # pylint: disable=unexpected-keyword-arg
index=index_name, size=3, body={"query": {"match_all": {}}}
)
assert len(initial_index["hits"]["hits"]) == 3
for embedded_hit in initial_index["hits"]["hits"]:
assert embedded_hit["_source"]["chunks"] == None
assert embedded_hit["_source"]["chunks"] is None
assert embedded_hit["_source"]["embedding_model"] is None
# enable hybrid search
@@ -85,7 +85,7 @@ def test_reindex_with_embedding_command(settings):
call_command("reindex_with_embedding", SERVICE_NAME)
opensearch_client_.indices.refresh(index=index_name)
embedded_index = opensearch_client_.search(
embedded_index = opensearch_client_.search( # pylint: disable=unexpected-keyword-arg
index=index_name, size=3, body={"query": {"match_all": {}}}
)
@@ -160,7 +160,7 @@ def test_reindex_can_fail_and_restart(settings):
# assert the index state
opensearch_client_.indices.refresh(index=index_name)
embedded_index = opensearch_client_.search(
embedded_index = opensearch_client_.search( # pylint: disable=unexpected-keyword-arg
index=index_name, size=3, body={"query": {"match_all": {}}}
)
# Should have 2 documents with embeddings, 1 without due to error
@@ -193,7 +193,7 @@ def test_reindex_can_fail_and_restart(settings):
# assert there is now 1 more success and 0 failures
opensearch_client_.indices.refresh(index=index_name)
embedded_index = opensearch_client_.search(
embedded_index = opensearch_client_.search( # pylint: disable=unexpected-keyword-arg
index=index_name, size=3, body={"query": {"match_all": {}}}
)
for hit in embedded_index["hits"]["hits"]:
@@ -255,7 +255,7 @@ def test_reindex_preserves_concurrent_updates(settings):
assert result["nb_failed_embedding"] == 0
opensearch_client_.indices.refresh(index=index_name)
embedded_index = opensearch_client_.search(
embedded_index = opensearch_client_.search( # pylint: disable=unexpected-keyword-arg
index=index_name, size=2, body={"query": {"match_all": {}}}
)
# Check that the latest update is preserved
@@ -1,3 +1,7 @@
"""Mock response for Albert embedding API."""
# pylint: disable=too-many-lines
response = {
"data": [
{
@@ -0,0 +1,384 @@
"""Tests for deleting documents from OpenSearch over the API"""
import opensearchpy
import pytest
import responses
from rest_framework.test import APIClient
from core import factories
from core.services.opensearch import opensearch_client
from core.utils import prepare_index
from .utils import build_authorization_bearer, setup_oicd_resource_server
pytestmark = pytest.mark.django_db
def test_api_documents_delete_anonymous():
"""Anonymous requests should not be allowed to delete documents."""
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": "service-name", "document_ids": ["doc1"]},
format="json",
)
assert response.status_code == 401
@responses.activate
def test_api_documents_delete_wrong_service_name(settings):
"""Requests with a wrong service name should return 400 Bad Request."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": "wrong-service", "document_ids": ["0"]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid request."
@responses.activate
def test_api_documents_delete_success(settings):
"""Authenticated users should be able to delete documents they have access to."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
# Create documents user has access to
documents = factories.DocumentSchemaFactory.build_batch(3, users=["user_sub"])
prepare_index(service.index_name, documents)
document_to_delete_ids = [doc["id"] for doc in documents[:2]]
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": service.name, "document_ids": document_to_delete_ids},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert response.json()["nb-deleted-documents"] == 2
assert response.json()["undeleted-document-ids"] == []
opensearch_client_ = opensearch_client()
for document in documents:
if document["id"] in document_to_delete_ids:
with pytest.raises(opensearchpy.exceptions.NotFoundError):
opensearch_client_.get(index=service.index_name, id=document["id"])
else:
doc = opensearch_client_.get(index=service.index_name, id=document["id"])
assert doc["found"]
@responses.activate
def test_api_documents_delete_no_access(settings):
"""Users should not be able to delete documents they don't have access to."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
# Create documents where user_sub does NOT have access
documents = factories.DocumentSchemaFactory.build_batch(2, users=["other_sub"])
prepare_index(service.index_name, documents)
document_ids = [doc["id"] for doc in documents]
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": service.name, "document_ids": document_ids},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert response.json()["nb-deleted-documents"] == 0
assert set(response.json()["undeleted-document-ids"]) == set(document_ids)
# Verify documents not deleted
opensearch_client_ = opensearch_client()
for doc_id in document_ids:
doc = opensearch_client_.get(index=service.index_name, id=doc_id)
assert doc["found"]
@responses.activate
def test_api_documents_delete_mixed_access(settings):
"""Deleting a mix of owned and non-owned documents should only delete owned ones."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
# Create documents with different access
owned_documents = factories.DocumentSchemaFactory.build_batch(2, users=["user_sub"])
other_documents = factories.DocumentSchemaFactory.build_batch(
2, users=["other_user"]
)
prepare_index(service.index_name, owned_documents + other_documents)
owned_document_ids = [doc["id"] for doc in owned_documents]
other_document_ids = [doc["id"] for doc in other_documents]
non_existing_document_ids = ["non-existent-1", "non-existent-2"]
response = APIClient().post(
"/api/v1.0/documents/delete/",
{
"service": service.name,
"document_ids": owned_document_ids
+ other_document_ids
+ non_existing_document_ids,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert response.json()["nb-deleted-documents"] == 2
assert set(response.json()["undeleted-document-ids"]) == set(
other_document_ids + non_existing_document_ids
)
# Verify only owned documents are deleted
opensearch_client_ = opensearch_client()
for document_id in owned_document_ids:
with pytest.raises(opensearchpy.exceptions.NotFoundError):
opensearch_client_.get(index=service.index_name, id=document_id)
for document_id in other_document_ids:
document = opensearch_client_.get(index=service.index_name, id=document_id)
assert document["found"]
@responses.activate
def test_api_documents_delete_invalid_params(settings):
"""Requests with invalid parameters should return 400 Bad Request."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
# Missing both document_ids and tags
response = APIClient().post(
"/api/v1.0/documents/delete/",
{
"service": service.name,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
assert (
response.json()[0]["msg"]
== "Value error, At least one of 'document_ids' or 'tags' must be provided"
)
# Empty document_ids and no tags
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": service.name, "document_ids": []},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
assert (
response.json()[0]["msg"]
== "Value error, At least one of 'document_ids' or 'tags' must be provided"
)
# Both empty
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": service.name, "document_ids": [], "tags": []},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
assert (
response.json()[0]["msg"]
== "Value error, At least one of 'document_ids' or 'tags' must be provided"
)
# Missing service
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"document_ids": ["doc1"]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
@responses.activate
def test_api_documents_delete_nonexistent_documents(settings):
"""
Deleting non-existent documents should not raise an error
and return the list of undeleted ids.
"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
# Create index but with no documents
prepare_index(service.index_name, [])
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": service.name, "document_ids": ["non-existent-id"]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert response.json()["nb-deleted-documents"] == 0
assert response.json()["undeleted-document-ids"] == ["non-existent-id"]
@responses.activate
def test_api_documents_delete_by_single_tag(settings):
"""Users should be able to delete documents by tags."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
document_to_deletes = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["delete-tag", "keep-tag-1"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["delete-tag", "keep-tag-2"]
),
]
document_to_keep = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["keep-tag-1", "keep-tag-2"]
),
factories.DocumentSchemaFactory.build(users=["user_sub"], tags=["keep-tag-1"]),
factories.DocumentSchemaFactory.build(
users=["other_user_sub"], tags=["delete-tag"]
),
]
prepare_index(service.index_name, document_to_deletes + document_to_keep)
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": service.name, "tags": ["delete-tag"]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert response.json()["nb-deleted-documents"] == 2
assert response.json()["undeleted-document-ids"] == []
opensearch_client_ = opensearch_client()
for document in document_to_deletes:
with pytest.raises(opensearchpy.exceptions.NotFoundError):
opensearch_client_.get(index=service.index_name, id=document["id"])
for document in document_to_keep:
doc = opensearch_client_.get(index=service.index_name, id=document["id"])
assert doc["found"]
@responses.activate
def test_api_documents_delete_by_multiple_tags(settings):
"""Users should be able to delete documents matching any of multiple tags."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
document_to_deletes = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["delete-tag-1", "keep-tag-1"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["delete-tag-1", "delete-tag-2"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["delete-tag-2"]
),
]
document_to_keep = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["keep-tag-1", "keep-tag-2"]
),
factories.DocumentSchemaFactory.build(users=["user_sub"], tags=["keep-tag-1"]),
factories.DocumentSchemaFactory.build(
users=["other_user_sub"], tags=["delete-tag-1"]
),
]
prepare_index(service.index_name, document_to_deletes + document_to_keep)
response = APIClient().post(
"/api/v1.0/documents/delete/",
{"service": service.name, "tags": ["delete-tag-1", "delete-tag-2"]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert response.json()["nb-deleted-documents"] == 3
assert response.json()["undeleted-document-ids"] == []
opensearch_client_ = opensearch_client()
for document in document_to_deletes:
with pytest.raises(opensearchpy.exceptions.NotFoundError):
opensearch_client_.get(index=service.index_name, id=document["id"])
for document in document_to_keep:
doc = opensearch_client_.get(index=service.index_name, id=document["id"])
assert doc["found"]
@responses.activate
def test_api_documents_delete_by_ids_and_tags(settings):
"""Users should be able to delete documents by both IDs and tags (AND logic)."""
setup_oicd_resource_server(responses, settings, sub="user_sub")
service = factories.ServiceFactory()
document_delete_by_tag_and_id = factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["delete-tag"]
)
document_delete_by_tag_keep_by_id = factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["delete-tag"]
)
document_keep_by_tag_delete_by_id = factories.DocumentSchemaFactory.build(
users=["user_sub"]
)
prepare_index(
service.index_name,
[
document_delete_by_tag_and_id,
document_delete_by_tag_keep_by_id,
document_keep_by_tag_delete_by_id,
],
)
response = APIClient().post(
"/api/v1.0/documents/delete/",
{
"service": service.name,
"document_ids": [
document_delete_by_tag_and_id["id"],
document_keep_by_tag_delete_by_id["id"],
],
"tags": ["delete-tag"],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert response.json()["nb-deleted-documents"] == 1
assert response.json()["undeleted-document-ids"] == [
document_keep_by_tag_delete_by_id["id"]
]
opensearch_client_ = opensearch_client()
with pytest.raises(opensearchpy.exceptions.NotFoundError):
opensearch_client_.get(
index=service.index_name, id=document_delete_by_tag_and_id["id"]
)
doc = opensearch_client_.get(
index=service.index_name, id=document_delete_by_tag_keep_by_id["id"]
)
assert doc["found"]
@@ -5,6 +5,7 @@ Don't use pytest parametrized tests because batch generation and indexing
of documents is slow and better done only once.
"""
import logging
import operator
import random
@@ -19,6 +20,7 @@ from core.services.opensearch import (
)
from core.utils import bulk_create_documents, prepare_index
from ..enums import SearchTypeEnum
from .mock import albert_embedding_response
from .utils import (
build_authorization_bearer,
@@ -100,8 +102,6 @@ def test_api_documents_search_query_unknown_user(settings):
introspect=lambda request, user_info: (404, {}, ""),
)
token = build_authorization_bearer()
service = factories.ServiceFactory()
prepare_index(service.index_name, [])
@@ -109,11 +109,10 @@ def test_api_documents_search_query_unknown_user(settings):
"/api/v1.0/documents/search/",
{"q": "a quick fox"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 401
assert response.json() == {"detail": "Login failed"}
assert response.status_code == 400
@responses.activate
@@ -180,7 +179,6 @@ def test_api_documents_search_reached_docs_invalid_parameters(settings):
def test_api_documents_search_match_all(settings):
"""Searching a document with q='*' should match all docs"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -198,7 +196,7 @@ def test_api_documents_search_match_all(settings):
"/api/v1.0/documents/search/",
{"q": "*", "visited": [doc["id"] for doc in documents]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -238,6 +236,7 @@ def test_api_documents_full_text_search_query_title(settings):
assert list(fox_response.keys()) == ["_index", "_id", "_score", "_source", "fields"]
assert fox_response["_id"] == str(documents[0]["id"])
assert fox_response["_source"] == {
"content.en": "the wolf",
"depth": 1,
"numchild": 0,
"path": fox_document["path"],
@@ -261,6 +260,7 @@ def test_api_documents_full_text_search_query_title(settings):
]
assert other_fox_response["_id"] == str(other_fox_document["id"])
assert other_fox_response["_source"] == {
"content.en": fox_document["content"],
"depth": 1,
"numchild": 0,
"path": other_fox_document["path"],
@@ -283,7 +283,6 @@ def test_api_documents_full_text_search(settings):
Searching a document by its content should work as expected.
"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
service = factories.ServiceFactory()
documents = bulk_create_documents(
@@ -299,7 +298,7 @@ def test_api_documents_full_text_search(settings):
"/api/v1.0/documents/search/",
{"q": "a quick fox", "visited": [doc["id"] for doc in documents]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -311,6 +310,7 @@ def test_api_documents_full_text_search(settings):
assert fox_response["_id"] == str(fox_document["id"])
assert fox_response["_score"] > 0
assert fox_response["_source"] == {
"content.en": "the wolf",
"depth": 1,
"numchild": 0,
"path": fox_document["path"],
@@ -335,6 +335,7 @@ def test_api_documents_full_text_search(settings):
assert other_fox_response["_id"] == str(other_fox_document["id"])
assert other_fox_response["_score"] > 0
assert other_fox_response["_source"] == {
"content.en": fox_document["content"],
"depth": 1,
"numchild": 0,
"path": other_fox_document["path"],
@@ -351,11 +352,47 @@ def test_api_documents_full_text_search(settings):
}
@responses.activate
def test_api_documents_search_with_search_type_full_text(settings, caplog):
"""Test API with search_type=full_text forces full-text search even if hybrid is enabled"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
enable_hybrid_search(settings)
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
service = factories.ServiceFactory()
documents = bulk_create_documents(
[
{"title": "wolf", "content": "wolves live in packs and hunt together"},
{"title": "dog", "content": "dogs are loyal domestic animals"},
]
)
prepare_index(service.index_name, documents)
with caplog.at_level(logging.INFO):
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "wolf",
"search_type": SearchTypeEnum.FULL_TEXT,
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert any(
"Performing full-text search without embedding: wolf" in message
for message in caplog.messages
)
@responses.activate
def test_api_documents_hybrid_search(settings):
"""Searching a document by its content should work as expected"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
# hybrid search is enabled by default
enable_hybrid_search(settings)
responses.add(
@@ -379,7 +416,7 @@ def test_api_documents_hybrid_search(settings):
"/api/v1.0/documents/search/",
{"q": "a quick fox", "visited": [doc["id"] for doc in documents]},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -393,6 +430,7 @@ def test_api_documents_hybrid_search(settings):
assert fox_response["_id"] == str(fox_document["id"])
assert fox_response["_score"] > 0
assert fox_response["_source"] == {
"content.en": fox_document["content"],
"depth": 1,
"numchild": 0,
"path": fox_document["path"],
@@ -417,6 +455,7 @@ def test_api_documents_hybrid_search(settings):
assert other_fox_response["_id"] == str(other_fox_document["id"])
assert other_fox_response["_score"] > 0
assert other_fox_response["_source"] == {
"content.en": fox_document["content"],
"depth": 1,
"numchild": 0,
"path": other_fox_document["path"],
@@ -443,6 +482,7 @@ def test_api_documents_hybrid_search(settings):
]
assert no_fox_response["_id"] == str(no_fox_document["id"])
assert no_fox_response["_source"] == {
"content.en": fox_document["content"],
"depth": 1,
"numchild": 0,
"path": no_fox_document["path"],
@@ -463,7 +503,6 @@ def test_api_documents_hybrid_search(settings):
def test_api_documents_search_ordering_by_fields(settings):
"""It should be possible to order by several fields"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -497,7 +536,7 @@ def test_api_documents_search_ordering_by_fields(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -514,7 +553,6 @@ def test_api_documents_search_ordering_by_fields(settings):
def test_api_documents_search_ordering_by_relevance(settings):
"""It should be possible to order by relevance (score)"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -537,7 +575,7 @@ def test_api_documents_search_ordering_by_relevance(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -554,7 +592,6 @@ def test_api_documents_search_ordering_by_relevance(settings):
def test_api_documents_search_ordering_by_unknown_field(settings):
"""Trying to sort by an unknown field should return a 400 error"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -582,7 +619,7 @@ def test_api_documents_search_ordering_by_unknown_field(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
@@ -602,7 +639,6 @@ def test_api_documents_search_ordering_by_unknown_field(settings):
def test_api_documents_search_ordering_by_unknown_direction(settings):
"""Trying to sort with an unknown direction should return a 400 error"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -625,7 +661,7 @@ def test_api_documents_search_ordering_by_unknown_direction(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
@@ -642,7 +678,6 @@ def test_api_documents_search_ordering_by_unknown_direction(settings):
def test_api_documents_search_filtering_by_reach(settings):
"""It should be possible to filter results by their reach"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -664,7 +699,7 @@ def test_api_documents_search_filtering_by_reach(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -678,7 +713,6 @@ def test_api_documents_search_filtering_by_reach(settings):
def test_api_documents_search_with_nb_results(settings):
"""nb_size should correctly return results of given size"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -701,7 +735,7 @@ def test_api_documents_search_with_nb_results(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -717,7 +751,7 @@ def test_api_documents_search_with_nb_results(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
data = response.json()
@@ -732,7 +766,7 @@ def test_api_documents_search_with_nb_results(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
data = response.json()
@@ -744,7 +778,6 @@ def test_api_documents_search_with_nb_results(settings):
def test_api_documents_search_nb_results_invalid_parameters(settings):
"""Invalid nb_results parameters should result in a 400 error"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -773,7 +806,7 @@ def test_api_documents_search_nb_results_invalid_parameters(settings):
"/api/v1.0/documents/search/",
{"q": "*", "nb_results": nb_results},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 400
@@ -785,7 +818,6 @@ def test_api_documents_search_nb_results_invalid_parameters(settings):
def test_api_documents_search_nb_results_with_filtering(settings):
"""nb_results should work correctly when combined with filtering by reach"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -810,7 +842,7 @@ def test_api_documents_search_nb_results_with_filtering(settings):
"visited": public_ids,
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert [r["_id"] for r in response.json()] == public_ids[0:nb_results]
@@ -820,7 +852,6 @@ def test_api_documents_search_nb_results_with_filtering(settings):
def test_api_documents_search_filtering_by_tags(settings):
"""Test filtering documents by a single tag via API"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -859,7 +890,7 @@ def test_api_documents_search_filtering_by_tags(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
@@ -872,7 +903,6 @@ def test_api_documents_search_filtering_by_tags(settings):
def test_api_documents_search_without_tags_filter(settings):
"""Test that search works normally when no tags filter is provided"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
token = build_authorization_bearer()
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
@@ -906,8 +936,60 @@ def test_api_documents_search_without_tags_filter(settings):
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert len(response.json()) == 2
@responses.activate
def test_api_documents_search_filtering_by_path(settings):
"""Test filtering documents by path prefix via API"""
setup_oicd_resource_server(responses, settings, sub="user_sub")
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
service = factories.ServiceFactory()
documents = bulk_create_documents(
[
{
"title": "Document with tags",
"content": "Tagged document",
"path": "/path/to/doc1",
},
{
"title": "Document without tags",
"content": "Untagged document",
"path": "/path/to/doc2",
},
{
"title": "Document without tags",
"content": "Untagged document",
"path": "other/path/to/doc3",
},
]
)
prepare_index(service.index_name, documents)
path_filter = "/path/to/"
response = APIClient().post(
"/api/v1.0/documents/search/",
{
"q": "*",
"path": path_filter,
"visited": [doc["id"] for doc in documents],
},
format="json",
HTTP_AUTHORIZATION=f"Bearer {build_authorization_bearer()}",
)
assert response.status_code == 200
assert len(response.json()) == 2
for hit in response.json():
assert hit["_source"]["path"].startswith(path_filter)
@@ -395,7 +395,7 @@ def test_api_documents_search_access__request_services(settings):
)
assert response.status_code == 400
assert response.json() == {"detail": "Some requested services are not available"}
assert response.json() == {"detail": "Invalid request."}
@responses.activate
@@ -419,7 +419,7 @@ def test_api_documents_search_access__request_inactive_services(settings):
)
assert response.status_code == 400
assert response.json() == {"detail": "Service is not available"}
assert response.json() == {"detail": "Invalid request."}
# Event without explicit argument, the client service from the request is not active
response = APIClient().post(
@@ -430,7 +430,7 @@ def test_api_documents_search_access__request_inactive_services(settings):
)
assert response.status_code == 400
assert response.json() == {"detail": "Service is not available"}
assert response.json() == {"detail": "Invalid request."}
@responses.activate
+313 -64
View File
@@ -9,7 +9,7 @@ from json import dumps as json_dumps
import pytest
import responses
from core import factories
from core import enums, factories
from core.services import opensearch
from core.services.opensearch import check_hybrid_search_enabled, opensearch_client
from core.services.search import search
@@ -40,6 +40,7 @@ def search_params(service):
"groups": [],
"visited": [],
"tags": [],
"search_type": enums.SearchTypeEnum.HYBRID,
}
@@ -199,6 +200,114 @@ def test_fall_back_on_full_text_search_if_hybrid_search_disabled(settings, caplo
assert result["hits"]["hits"][0]["_source"]["title.en"] == "wolf"
@responses.activate
def test_force_full_text_search_with_search_type_parameter(settings, caplog):
"""Test the full-text search is done when search_type=FULL_TEXT even if hybrid is enabled"""
enable_hybrid_search(settings)
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
documents = bulk_create_documents(
[
{"title": "wolf", "content": "wolves live in packs and hunt together"},
{"title": "dog", "content": "dogs are loyal domestic animals"},
{"title": "cat", "content": "cats are curious and independent pets"},
]
)
service = factories.ServiceFactory(name=SERVICE_NAME)
prepare_index(service.index_name, documents)
q = "wolf"
with caplog.at_level(logging.INFO):
result = search(
q=q,
**{**search_params(service), "search_type": enums.SearchTypeEnum.FULL_TEXT},
)
assert any(
f"Performing full-text search without embedding: {q}" in message
for message in caplog.messages
)
assert result["hits"]["max_score"] > 0.0
assert len(result["hits"]["hits"]) == 1
assert result["hits"]["hits"][0]["_source"]["title.en"] == "wolf"
def test_request_hybrid_search_when_server_has_it_disabled(settings, caplog):
"""Test warning when hybrid search is requested but disabled on server"""
enable_hybrid_search(settings)
settings.HYBRID_SEARCH_ENABLED = False
documents = bulk_create_documents(
[
{"title": "wolf", "content": "wolves live in packs and hunt together"},
{"title": "dog", "content": "dogs are loyal domestic animals"},
{"title": "cat", "content": "cats are curious and independent pets"},
]
)
service = factories.ServiceFactory(name=SERVICE_NAME)
prepare_index(service.index_name, documents)
q = "canine pet"
with caplog.at_level(logging.INFO):
search(
q=q,
**{**search_params(service), "search_type": enums.SearchTypeEnum.HYBRID},
)
assert any(
"Hybrid search was requested (search_type=hybrid) but is disabled on server"
in message
for message in caplog.messages
)
assert any(
f"Performing full-text search without embedding: {q}" in message
for message in caplog.messages
)
@responses.activate
def test_api_documents_search_with_search_type_hybrid(settings, caplog):
"""Test API with search_type=hybrid uses hybrid search when enabled"""
enable_hybrid_search(settings)
responses.add(
responses.POST,
settings.EMBEDDING_API_PATH,
json=albert_embedding_response.response,
status=200,
)
service = factories.ServiceFactory()
documents = bulk_create_documents(
[
{"title": "wolf", "content": "wolves live in packs and hunt together"},
{"title": "dog", "content": "dogs are loyal domestic animals"},
]
)
prepare_index(service.index_name, documents)
q = "canine pet"
with caplog.at_level(logging.INFO):
result = search(
q=q,
**{**search_params(service), "search_type": enums.SearchTypeEnum.HYBRID},
)
assert any(
f"Performing hybrid search with embedding: {q}" in message
for message in caplog.messages
)
assert result["hits"]["max_score"] > 0.0
# hybrid search always returns a response of fixed sized sorted and scored by relevance
assert {hit["_source"]["title.en"] for hit in result["hits"]["hits"]} == {
doc["title"] for doc in documents
}
@responses.activate
def test_fall_back_on_full_text_search_if_embedding_api_fails(settings, caplog):
"""Test the full-text search is done when the embedding api fails"""
@@ -367,75 +476,73 @@ def test_search_filtering_by_single_tag():
"""Test filtering documents by a single tag"""
service = factories.ServiceFactory(name=SERVICE_NAME)
documents = bulk_create_documents(
[
{
"title": "Document with python tag",
"content": "This is about Python programming",
"tags": ["python", "programming"],
},
{
"title": "Document with javascript tag",
"content": "This is about JavaScript",
"tags": ["javascript", "programming"],
},
{
"title": "Document with no tags",
"content": "This has no tags",
"tags": [],
},
]
)
documents_to_search = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-search"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-search", "tag-to-filter"]
),
]
documents_to_filter = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-filter"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
),
]
expected_ids = {str(doc["id"]) for doc in documents_to_search}
prepare_index(service.index_name, documents)
prepare_index(service.index_name, documents_to_search + documents_to_filter)
# Search for documents with python tag
result = search(q="*", **{**search_params(service), "tags": ["python"]})
# Search for documents with tag-to-search tag
result = search(q="*", **{**search_params(service), "tags": ["tag-to-search"]})
returned_ids = {hit["_id"] for hit in result["hits"]["hits"]}
assert result["hits"]["total"]["value"] == 1
assert result["hits"]["hits"][0]["_id"] == str(documents[0]["id"])
assert result["hits"]["total"]["value"] == len(documents_to_search)
assert returned_ids == expected_ids
def test_search_filtering_by_multiple_tags():
"""Test filtering documents by multiple tags (OR logic)"""
service = factories.ServiceFactory(name=SERVICE_NAME)
documents = bulk_create_documents(
[
{
"title": "Document with python tag",
"content": "This is about Python programming",
"tags": ["python", "backend"],
},
{
"title": "Document with javascript tag",
"content": "This is about JavaScript",
"tags": ["javascript", "frontend"],
},
{
"title": "Document with java tag",
"content": "This is about Java",
"tags": ["java", "backend"],
},
{
"title": "Document with no tags",
"content": "This has no tags",
"tags": [],
},
]
)
documents_to_search = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-search-1"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-search-1", "tag-to-filter"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-search-2", "tag-to-filter"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-search-1", "tag-to-search-2"]
),
]
documents_to_filter = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], tags=["tag-to-filter"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
),
]
expected_ids = {str(doc["id"]) for doc in documents_to_search}
prepare_index(service.index_name, documents)
prepare_index(service.index_name, documents_to_search + documents_to_filter)
# Search for documents with python OR javascript tags
# Search for documents with tag-to-search-1 OR tag-to-search-2 tags
result = search(
q="*", **{**search_params(service), "tags": ["python", "javascript"]}
q="*",
**{**search_params(service), "tags": ["tag-to-search-1", "tag-to-search-2"]},
)
assert result["hits"]["total"]["value"] == 2
returned_ids = {hit["_id"] for hit in result["hits"]["hits"]}
assert str(documents[0]["id"]) in returned_ids
assert str(documents[1]["id"]) in returned_ids
assert result["hits"]["total"]["value"] == len(documents_to_search)
assert returned_ids == expected_ids
def test_search_no_tags_filter_returns_all():
@@ -444,22 +551,164 @@ def test_search_no_tags_filter_returns_all():
documents = bulk_create_documents(
[
{
"title": "Document with tags",
"content": "Tagged document",
"tags": ["python"],
},
{
"title": "Document without tags",
"content": "Untagged document",
"tags": [],
},
{
"title": "Document with tags",
"content": "Tagged document",
"tags": ["tag-to-search"],
},
]
)
prepare_index(service.index_name, documents)
# Search without tags filter
result = search(q="*", **search_params(service))
assert result["hits"]["total"]["value"] == 2
assert result["hits"]["total"]["value"] == len(documents)
def test_search_filtering_by_tag_and_query():
"""Test filtering documents by both tag and query text"""
service = factories.ServiceFactory(name=SERVICE_NAME)
documents_to_search = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], title="title to search", tags=["tag-to-search"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 1",
tags=["tag-to-search", "tag-to-filter"],
),
]
documents_to_filter = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], title="title to filter", tags=["tag-to-filter"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], title="title to filter 1", tags=["tag-to-filter"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 2",
),
]
expected_ids = {str(doc["id"]) for doc in documents_to_search}
prepare_index(service.index_name, documents_to_search + documents_to_filter)
# Search with both query and tag filter
result = search(q="search", **{**search_params(service), "tags": ["tag-to-search"]})
returned_ids = {hit["_id"] for hit in result["hits"]["hits"]}
assert result["hits"]["total"]["value"] == len(documents_to_search)
assert returned_ids == expected_ids
def test_search_filtering_by_path():
"""Test filtering documents by path prefix"""
service = factories.ServiceFactory(name=SERVICE_NAME)
documents_to_search = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], path="path/to/search/doc-1"
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], path="path/to/search/doc-2"
),
]
documents_to_filter = [
factories.DocumentSchemaFactory.build(
users=["user_sub"], path="path/to/filter/doc-3"
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
),
]
expected_ids = {str(doc["id"]) for doc in documents_to_search}
prepare_index(service.index_name, documents_to_search + documents_to_filter)
path_filter = "path/to/search"
result = search(q="*", **{**search_params(service), "path": path_filter})
returned_ids = {hit["_id"] for hit in result["hits"]["hits"]}
assert result["hits"]["total"]["value"] == len(documents_to_search)
assert returned_ids == expected_ids
def test_search_filtering_by_query_path_and_tag():
"""Test filtering documents by query text, path prefix and tag combined"""
service = factories.ServiceFactory(name=SERVICE_NAME)
documents_to_search = [
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 0",
path="path/to/search-0",
tags=["tag-to-search"],
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 1",
path="path/to/search/doc1",
tags=["tag-to-search", "tag-to-filter"],
),
]
documents_to_filter = [
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to filter",
path="path/to/search/doc-3",
tags=["tag-to-search"],
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 4",
path="path/to/filter/doc-4",
tags=["tag-to-search"],
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 4",
path="path/to/search/doc-4",
),
factories.DocumentSchemaFactory.build(
users=["user_sub"], title="title to search 5", tags=["tag-to-search"]
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 6",
path="path/to/search/doc-6",
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="title to search 7",
),
factories.DocumentSchemaFactory.build(
users=["user_sub"],
title="",
path="path/to/search/doc-8",
tags=["tag-to-search"],
),
]
expected_ids = {str(doc["id"]) for doc in documents_to_search}
prepare_index(service.index_name, documents_to_search + documents_to_filter)
# Search with query, path and tag filters combined
result = search(
q="search",
**{
**search_params(service),
"path": "path/to/search",
"tags": ["tag-to-search"],
},
)
returned_ids = {hit["_id"] for hit in result["hits"]["hits"]}
assert result["hits"]["total"]["value"] == len(documents_to_search)
assert returned_ids == expected_ids
+2 -2
View File
@@ -83,12 +83,12 @@ def setup_oicd_resource_server(
if callable(introspect):
responses.add_callback(
responses.POST,
"https://oidc.example.com/introspect",
settings.OIDC_OP_INTROSPECTION_ENDPOINT,
callback=partial(introspect, user_info=token_data),
)
else:
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
settings.OIDC_OP_INTROSPECTION_ENDPOINT,
body=json.dumps(token_data),
)
+2 -1
View File
@@ -2,10 +2,11 @@
from django.urls import include, path
from .views import IndexDocumentView, SearchDocumentView
from .views import DeleteDocumentsView, IndexDocumentView, SearchDocumentView
urlpatterns = [
path("documents/index/", IndexDocumentView.as_view(), name="document"),
path("documents/search/", SearchDocumentView.as_view(), name="document"),
path("documents/delete/", DeleteDocumentsView.as_view(), name="document"),
path("", include("lasuite.oidc_resource_server.urls")),
]
+158 -31
View File
@@ -12,11 +12,16 @@ from rest_framework.response import Response
from . import schemas
from .authentication import ServiceTokenAuthentication
from .models import Service, get_opensearch_index_name
from .enums import SearchTypeEnum
from .permissions import IsAuthAuthenticated
from .services.indexing import ensure_index_exists, prepare_document_for_indexing
from .services.opensearch import opensearch_client
from .services.indexing import (
ensure_index_exists,
get_opensearch_indices,
prepare_document_for_indexing,
)
from .services.opensearch import check_hybrid_search_enabled, opensearch_client
from .services.search import search
from .utils import get_language_value
logger = logging.getLogger(__name__)
@@ -106,6 +111,11 @@ class IndexDocumentView(views.APIView):
schemas.DocumentSchema(**request.data).model_dump()
)
_id = document_dict.pop("id")
logger.info(
"Indexing single document %s on index %s",
get_language_value(document_dict, "title"),
index_name,
)
ensure_index_exists(index_name)
opensearch_client_.index(
@@ -149,6 +159,11 @@ class IndexDocumentView(views.APIView):
has_errors = True
else:
document_dict = prepare_document_for_indexing(document.model_dump())
logger.info(
"Indexing document %s on index %s",
get_language_value(document_dict, "title"),
index_name,
)
_id = document_dict.pop("id")
actions.append({"index": {"_id": _id}})
actions.append(document_dict)
@@ -171,6 +186,118 @@ class IndexDocumentView(views.APIView):
return Response(results, status=status.HTTP_201_CREATED)
class DeleteDocumentsView(ResourceServerMixin, views.APIView):
"""
API view for deleting documents from OpenSearch.
- Allows authenticated users to delete documents from a specified index.
- Users can only delete documents where they are listed in the 'users' field.
- Returns the count of deleted documents without revealing document existence.
"""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [IsAuthAuthenticated]
def post(self, request, *args, **kwargs):
"""
Handle POST requests to delete documents from the specified index.
Only documents where the authenticated user is in the 'users' field will be deleted.
Body Parameters:
---------------
service: str
service name to determine the index from which to delete documents.
document_ids : List[str], optional
A list of document IDs to delete from the index.
tags : List[str], optional
A list of tags to filter documents for deletion.
At least one of document_ids or tags must be provided.
The list of ids and the list of tags are combined with AND logic.
Returns:
--------
Response : rest_framework.response.Response
- 200 OK: returns a JSON object with the following keys:
- nb-deleted-documents: Number of documents deleted.
- undeleted-document-ids: sublist of param.document_ids that were not deleted.
Deletion may be prevented because the document does not exist,
because the user is not authorized to delete it or because a tag filter was used.
- 400 Bad Request: If parameters are invalid or missing.
"""
params = schemas.DeleteDocumentsSchema(**request.data)
try:
index_name = get_opensearch_indices(
self._get_service_provider_audience(), services=[params.service]
)[0]
except SuspiciousOperation as e:
logger.error(e)
return Response(
{"detail": "Invalid request."},
status=status.HTTP_400_BAD_REQUEST,
)
logger.info(
"Deleting documents from index %s with filters: document_ids=%s, tags=%s",
index_name,
params.document_ids,
params.tags,
)
client = opensearch_client()
deletable_matches = client.search(
index=index_name,
body={
"query": self._build_query(
self.request.user.sub,
document_ids=params.document_ids,
tags=params.tags,
)
},
)
deletable_ids = [hit["_id"] for hit in deletable_matches["hits"]["hits"]]
if deletable_ids:
response = client.delete_by_query(
index=index_name,
body={"query": {"ids": {"values": deletable_ids}}},
)
nb_deleted = response.get("deleted", 0)
else:
nb_deleted = 0
return Response(
{
"nb-deleted-documents": nb_deleted,
"undeleted-document-ids": [
document_id
for document_id in params.document_ids or []
if document_id not in deletable_ids
],
},
status=status.HTTP_200_OK,
)
def _build_query(self, user_sub, document_ids=None, tags=None):
"""
Build OpenSearch query for document deletion.
Args:
user_sub: User subject identifier for authorization.
document_ids: Optional list of document IDs to filter.
tags: Optional list of tags to filter.
Returns:
Deletion OpenSearch query.
"""
filters = [{"term": {"users": user_sub}}]
if document_ids:
filters.append({"ids": {"values": document_ids}})
if tags:
filters.append({"terms": {"tags": tags}})
return {"bool": {"must": filters}}
class SearchDocumentView(ResourceServerMixin, views.APIView):
"""
API view for searching documents in OpenSearch.
@@ -182,27 +309,6 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
authentication_classes = [ResourceServerAuthentication]
permission_classes = [IsAuthAuthenticated]
@staticmethod
def _get_opensearch_indices(audience, services):
# Get request user service
try:
user_service = Service.objects.get(client_id=audience, is_active=True)
except Service.DoesNotExist as e:
logger.warning("Login failed: No service %s found", audience)
raise SuspiciousOperation("Service is not available") from e
# Find allowed sub-services for this service
allowed_services = set(user_service.services.values_list("name", flat=True))
allowed_services.add(user_service.name)
if services:
available = set(services).intersection(allowed_services)
if len(available) < len(services):
raise SuspiciousOperation("Some requested services are not available")
return [get_opensearch_index_name(name) for name in allowed_services]
def post(self, request, *args, **kwargs):
"""
Handle POST requests to perform a search on indexed documents with optional filtering
@@ -223,6 +329,9 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
tags : List[str], optional
Filter results based on the 'tags' field. Documents matching any of the
provided tags will be returned.
path : str, optional
Filter results based on the 'path' field. Only documents whose path
starts with the provided value will be returned.
order_by : str, optional
Order results by 'relevance', 'created_at', 'updated_at', or 'size'.
Defaults to 'relevance' if not specified.
@@ -238,6 +347,13 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
List of public/authenticated documents the user has visited to limit
the document returned to the ones the current user has seen.
Built from linkreach list of a document in docs app.
search_type : str, optional
Type of search to perform: 'hybrid' or 'full_text'.
- 'hybrid': Uses hybrid search if enabled on the server,
otherwise falls back to full-text search.
- 'full_text': Uses only full-text search, even if hybrid search is enabled
on the server.
if the not specified, the server will use hybrid search when enabled
Returns:
--------
@@ -253,13 +369,16 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
# Get index list for search query
try:
search_indices = self._get_opensearch_indices(
audience, services=params.services
)
search_indices = get_opensearch_indices(audience, services=params.services)
except SuspiciousOperation as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
logger.error(e, exc_info=True)
return Response(
{"detail": "Invalid request."},
status=status.HTTP_400_BAD_REQUEST,
)
response = search(
logger.info("Search '%s' on indices %s", params.q, search_indices)
result = search(
q=params.q,
nb_results=params.nb_results,
order_by=params.order_by,
@@ -270,6 +389,14 @@ class SearchDocumentView(ResourceServerMixin, views.APIView):
user_sub=user_sub,
groups=groups,
tags=params.tags,
)
path=params.path,
search_type=params.search_type
if params.search_type
else SearchTypeEnum.HYBRID
if check_hybrid_search_enabled()
else SearchTypeEnum.FULL_TEXT,
)["hits"]["hits"]
logger.info("found %d results", len(result))
logger.debug("results %s", result)
return Response(response["hits"]["hits"], status=status.HTTP_200_OK)
return Response(result, status=status.HTTP_200_OK)
+5
View File
@@ -13,4 +13,9 @@ DEV_SERVICES = (
"client_id": "drive",
"token": "find-api-key-for-driv-with-exactly-50-chars-length",
},
{
"name": "conversations",
"client_id": "conversations",
"token": "find-api-key-for-conv-with-exactly-50-chars-length",
},
)
@@ -26,7 +26,7 @@ def test_commands_create_demo():
"""The create_demo management command should create objects as expected."""
call_command("create_demo")
assert models.Service.objects.exclude(name="docs").count() == 3
assert models.Service.objects.exclude(name="docs").count() == 4
assert opensearch_client().count()["count"] == 4
docs = models.Service.objects.get(name="docs")
@@ -0,0 +1,903 @@
"""a simple corpus of documents for evaluation"""
documents = [
{
"id": "sc-1",
"title": "La Révolution Française 1789",
"content": (
"La Révolution française débute le 14 juillet 1789 avec la prise de la "
"Bastille, symbole de l'absolutisme royal. Les causes sont multiples : crise "
"financière, famine, influence des Lumières et inégalités sociales criantes. "
"L'Assemblée Constituante abolit les privilèges le 4 août et adopte la "
"Déclaration des Droits de l'Homme le 26 août. Le roi Louis XVI est exécuté "
"en 1793 après la proclamation de la République. Cette révolution transforme "
"profondément la France et influence l'Europe entière."
),
"updated_at": "2026-02-24T12:00:00Z",
},
{
"id": "sc-2",
"title": "La Première Guerre Mondiale",
"content": (
"La Première Guerre mondiale (1914-1918) oppose les Alliés (France, Royaume- "
"Uni, Russie) aux Empires centraux (Allemagne, Autriche-Hongrie). "
"L'assassinat de l'archiduc François-Ferdinand à Sarajevo déclenche le "
"conflit par le jeu des alliances. La guerre de tranchées caractérise le "
"front occidental avec Verdun et la Somme. Les nouvelles armes (gaz, tanks, "
"aviation) causent 18 millions de morts. Le traité de Versailles en 1919 "
"redessine la carte de l'Europe."
),
},
{
"id": "sc-3",
"title": "La Décolonisation en Afrique",
"content": (
"La décolonisation de l'Afrique s'accélère après 1945, affaiblie par la "
"Seconde Guerre mondiale. Le Ghana obtient son indépendance en 1957, suivi de "
"17 pays en 1960, 'l'année de l'Afrique'. Le processus varie : négociation "
"pacifique (Afrique francophone) ou luttes armées (Algérie, Kenya). Les "
"frontières héritées de la colonisation créent des tensions ethniques. Les "
"nouveaux États font face à des défis économiques et politiques majeurs."
),
},
{
"id": "sc-4",
"title": "Le Réchauffement Climatique",
"content": (
"Le réchauffement climatique désigne l'augmentation de la température moyenne "
"terrestre causée par les émissions de gaz à effet de serre. Depuis l'ère "
"industrielle, la température a augmenté de 1,2°C. Les conséquences incluent "
"fonte des glaciers, montée des eaux, événements météorologiques extrêmes et "
"migrations climatiques. Les accords de Paris en 2015 visent à limiter le "
"réchauffement à 1,5°C. La transition énergétique et les énergies "
"renouvelables sont essentielles."
),
},
{
"id": "sc-5",
"title": "L'Empire Romain",
"content": (
"L'Empire romain domine le bassin méditerranéen pendant cinq siècles, de 27 "
"avant J.-C. à 476 après J.-C. Auguste, premier empereur, établit la Pax "
"Romana qui apporte prospérité et stabilité. Rome construit routes, aqueducs "
"et monuments grandioses comme le Colisée. Le christianisme devient religion "
"d'État en 380. Les invasions barbares et les crises internes provoquent la "
"chute de l'Empire d'Occident en 476."
),
},
{
"id": "sc-6",
"title": "La Renaissance Européenne",
"content": (
"La Renaissance (XIVe-XVIe siècles) marque un renouveau artistique, "
"scientifique et intellectuel en Europe. Née en Italie avec Florence et les "
"Médicis, elle redécouvre l'Antiquité gréco-romaine. Léonard de Vinci, "
"Michel-Ange et Raphaël révolutionnent l'art. Gutenberg invente l'imprimerie "
"en 1450, facilitant la diffusion des savoirs. Les grandes découvertes "
"ouvrent le monde avec Colomb et Magellan. L'humanisme place l'homme au "
"centre de la réflexion."
),
},
{
"id": "sc-7",
"title": "La Guerre Froide",
"content": (
"La Guerre Froide (1947-1991) oppose les États-Unis capitalistes à l'URSS "
"communiste sans conflit direct. Le rideau de fer divise l'Europe entre blocs "
"occidental et oriental. Les crises majeures incluent Berlin (1948, 1961), "
"Cuba (1962) et le Vietnam. La course aux armements nucléaires menace "
"l'humanité. La détente des années 1970 laisse place aux tensions des années "
"1980. La chute du mur de Berlin en 1989 symbolise la fin de la Guerre "
"Froide."
),
},
{
"id": "sc-8",
"title": "L'Égypte Ancienne",
"content": (
"L'Égypte ancienne prospère pendant 3000 ans le long du Nil, de 3100 avant "
"J.-C. à 30 avant J.-C. Les pharaons sont considérés comme des dieux vivants. "
"Les pyramides de Gizeh, notamment celle de Khéops, témoignent de prouesses "
"architecturales. L'écriture hiéroglyphique permet l'administration et la "
"transmission des savoirs. Le culte des morts et la momification préparent à "
"la vie après la mort. Cléopâtre VII est la dernière souveraine avant la "
"conquête romaine."
),
},
{
"id": "sc-9",
"title": "La Mondialisation",
"content": (
"La mondialisation désigne l'intégration croissante des économies et sociétés "
"à l'échelle planétaire. Accélérée depuis 1990, elle s'appuie sur les "
"nouvelles technologies, les transports et la libéralisation des échanges. "
"Les firmes multinationales organisent la production mondiale. Les flux "
"commerciaux, financiers et migratoires s'intensifient. Ce processus crée des "
"interdépendances mais accentue aussi les inégalités entre pays développés et "
"en développement."
),
},
{
"id": "sc-10",
"title": "La Conquête Spatiale",
"content": (
"La conquête spatiale débute en 1957 avec le satellite soviétique Spoutnik. "
"Youri Gagarine devient le premier homme dans l'espace en 1961. Les "
"Américains répondent avec le programme Apollo : Neil Armstrong marche sur la "
"Lune en 1969. La Station Spatiale Internationale symbolise la coopération "
"internationale depuis 1998. Aujourd'hui, les ambitions incluent Mars, et des "
"entreprises privées comme SpaceX révolutionnent l'accès à l'espace."
),
},
{
"id": "sc-11",
"title": "Les Métropoles Mondiales",
"content": (
"Les métropoles mondiales comme New York, Londres, Tokyo et Paris concentrent "
"pouvoir économique, politique et culturel. Elles abritent sièges sociaux des "
"multinationales, bourses financières et institutions internationales. Ces "
"villes sont des nœuds de transport et communication globaux. Elles attirent "
"flux migratoires et talents créant diversité mais aussi gentrification. Les "
"mégapoles du Sud (Mumbai, São Paulo) connaissent une urbanisation rapide "
"avec des défis d'infrastructures."
),
},
{
"id": "sc-12",
"title": "La Seconde Guerre Mondiale",
"content": (
"La Seconde Guerre mondiale (1939-1945) est le conflit le plus meurtrier de "
"l'histoire avec 60 millions de morts. Hitler envahit la Pologne en septembre "
"1939, déclenchant la guerre. La France capitule en 1940 tandis que le "
"Royaume-Uni résiste. L'Allemagne attaque l'URSS en 1941 et le Japon Pearl "
"Harbor. Le débarquement en Normandie en 1944 libère l'Europe. La Shoah "
"extermine 6 millions de Juifs. Le conflit se termine avec les bombes "
"atomiques sur Hiroshima et Nagasaki."
),
},
{
"id": "sc-13",
"title": "Le Développement Durable",
"content": (
"Le développement durable vise à concilier croissance économique, protection "
"environnementale et équité sociale. Défini au Sommet de Rio en 1992, il "
"répond aux besoins présents sans compromettre ceux des générations futures. "
"Les 17 Objectifs de Développement Durable de l'ONU incluent éradication de "
"la pauvreté, éducation, égalité des genres et action climatique. Les "
"énergies renouvelables, l'économie circulaire et la consommation responsable "
"sont des leviers essentiels."
),
},
{
"id": "sc-14",
"title": "La Chine contemporaine",
"content": (
"La Chine est devenue la deuxième économie mondiale grâce aux réformes de "
"Deng Xiaoping en 1978. L'ouverture au capitalisme maintient le régime "
"communiste du Parti unique. Le pays est 'l'usine du monde' avec des zones "
"économiques spéciales. Les nouvelles routes de la soie étendent son "
"influence internationale. Avec 1,4 milliard d'habitants, la Chine fait face "
"à des défis environnementaux, démographiques et sociaux majeurs."
),
},
{
"id": "sc-15",
"title": "Les Grandes Découvertes",
"content": (
"Les grandes découvertes (XVe-XVIe siècles) voient les Européens explorer le "
"monde. Christophe Colomb atteint l'Amérique en 1492, croyant trouver les "
"Indes. Vasco de Gama ouvre la route des Indes en 1498. Magellan réalise le "
"premier tour du monde en 1522. Ces expéditions sont motivées par la "
"recherche d'épices, d'or et l'évangélisation. Elles inaugurent la "
"colonisation européenne et l'échange colombien qui transforment les "
"civilisations."
),
},
{
"id": "sc-16",
"title": "L'Union Européenne",
"content": (
"L'Union européenne naît du désir de paix après 1945. Le traité de Rome en "
"1957 crée la CEE avec six membres fondateurs. L'UE compte aujourd'hui 27 "
"États après le Brexit. Le marché unique permet libre circulation des biens, "
"services, capitaux et personnes. L'euro est adopté par 20 pays. L'UE fait "
"face à des défis : crise migratoire, euroscepticisme et divergences "
"économiques entre Nord et Sud."
),
},
{
"id": "sc-17",
"title": "La Révolution Industrielle",
"content": (
"La révolution industrielle débute en Angleterre au XVIIIe siècle avec la "
"machine à vapeur de Watt. L'industrie textile mécanisée précède la "
"métallurgie et les chemins de fer. Le charbon et la vapeur fournissent "
"l'énergie nécessaire. L'urbanisation s'accélère avec l'exode rural vers les "
"usines. Les conditions ouvrières misérables suscitent les mouvements sociaux "
"et le syndicalisme. Cette révolution transforme radicalement l'économie et "
"la société européennes."
),
},
{
"id": "sc-18",
"title": "Les Inégalités Nord-Sud",
"content": (
"Les inégalités Nord-Sud opposent pays développés et pays en développement. "
"L'Indice de Développement Humain mesure richesse, éducation et santé. Les "
"pays du Nord concentrent richesses et technologies tandis que le Sud subit "
"pauvreté et dépendance économique. L'héritage colonial, l'endettement et les "
"termes de l'échange inégaux perpétuent ces écarts. Les pays émergents "
"(BRICS) remettent en question cette division binaire."
),
},
{
"id": "sc-19",
"title": "Le Siècle des Lumières",
"content": (
"Le Siècle des Lumières (XVIIIe siècle) est un mouvement intellectuel "
"européen promouvant raison, science et progrès. Voltaire critique "
"l'intolérance religieuse, Montesquieu théorise la séparation des pouvoirs, "
"Rousseau défend la souveraineté populaire. L'Encyclopédie de Diderot et "
"d'Alembert diffuse les savoirs. Ces philosophes remettent en cause "
"l'absolutisme et les privilèges. Leurs idées inspirent les révolutions "
"américaine et française."
),
},
{
"id": "sc-20",
"title": "Les Ressources Énergétiques",
"content": (
"Les ressources énergétiques sont inégalement réparties sur la planète. Les "
"énergies fossiles (pétrole, gaz, charbon) dominent mais sont non "
"renouvelables et polluantes. Le Moyen-Orient concentre 48% des réserves "
"pétrolières mondiales. La Russie utilise le gaz comme arme géopolitique. Les "
"énergies renouvelables (solaire, éolien, hydraulique) progressent face au "
"réchauffement climatique. La transition énergétique est un défi majeur du "
"XXIe siècle pour assurer souveraineté et durabilité."
),
},
{
"id": "sc-21",
"title": "Le Lion d'Afrique",
"content": (
"Le lion est le plus grand félin d'Afrique et vit en groupe social appelé "
"troupe. Les mâles se distinguent par leur majestueuse crinière qui protège "
"leur cou lors des combats. Les lions chassent principalement au crépuscule, "
"les lionnes étant les principales chasseuses. Ils peuvent rugir jusqu'à 8 "
"kilomètres de distance pour marquer leur territoire. Malheureusement, leur "
"population a diminué de 43% en 20 ans."
),
},
{
"id": "sc-22",
"title": "Le Dauphin",
"content": (
"Les dauphins sont parmi les animaux les plus intelligents de la planète. Ils "
"communiquent par des clics et sifflements complexes, chaque individu "
"possédant sa propre signature sonore. Vivant en groupes sociaux "
"sophistiqués, ils chassent en coordination et s'entraident. Les dauphins "
"peuvent nager jusqu'à 30 km/h et plonger à 300 mètres de profondeur. Leur "
"cerveau possède plus de circonvolutions que celui de l'homme."
),
},
{
"id": "sc-23",
"title": "L'Éléphant d'Asie",
"content": (
"L'éléphant d'Asie est légèrement plus petit que son cousin africain mais "
"tout aussi majestueux. Ces herbivores peuvent consommer jusqu'à 150 kg de "
"végétation par jour et boire 140 litres d'eau. Leur trompe contient 40 000 "
"muscles et leur sert à manger, boire, se laver et communiquer. Les éléphants "
"vivent en matriarcat dirigé par la femelle la plus âgée. Leur mémoire "
"exceptionnelle leur permet de se souvenir des points d'eau sur de vastes "
"territoires."
),
},
{
"id": "sc-24",
"title": "Le Colibri",
"content": (
"Le colibri est le plus petit oiseau du monde, certaines espèces ne pesant "
"que 2 grammes. Ses ailes battent jusqu'à 80 fois par seconde, lui permettant "
"de faire du vol stationnaire et même de voler en arrière. Son métabolisme "
"est si rapide qu'il doit manger jusqu'à deux fois son poids en nectar "
"quotidiennement. Le colibri peut visiter 1000 fleurs par jour. Ses couleurs "
"iridescentes changent selon l'angle de la lumière."
),
},
{
"id": "sc-25",
"title": "L'Ours polaire",
"content": (
"L'ours polaire est le plus grand carnivore terrestre, pouvant peser jusqu'à "
"800 kg. Sa fourrure blanche et sa peau noire lui permettent d'absorber la "
"chaleur du soleil. Son odorat est si développé qu'il peut détecter un phoque "
"sous 1 mètre de glace à 1,5 km. Le réchauffement climatique menace gravement "
"son habitat."
),
},
{
"id": "sc-26",
"title": "Le Caméléon",
"content": (
"Le caméléon possède la capacité extraordinaire de changer de couleur selon "
"son humeur, la température et la communication sociale. Ses yeux peuvent "
"bouger indépendamment l'un de l'autre à 360 degrés. Sa langue peut s'étendre "
"jusqu'à deux fois la longueur de son corps pour capturer des insectes. "
"Certaines espèces mesurent seulement 3 cm tandis que d'autres atteignent 70 "
"cm. Ils se déplacent lentement et de manière saccadée pour imiter le "
"mouvement des feuilles."
),
},
{
"id": "sc-27",
"title": "Le Manchot Empereur",
"content": (
"Le manchot empereur survit aux conditions les plus extrêmes de la planète en "
"Antarctique. Les mâles couvent l'œuf unique pendant 64 jours dans des "
"températures atteignant -40°C sans manger. Ils se regroupent en tortue pour "
"se protéger du froid, tournant régulièrement pour partager la chaleur. Ces "
"oiseaux peuvent plonger jusqu'à 500 mètres de profondeur et retenir leur "
"respiration 22 minutes. Leur parade nuptiale comprend des chants complexes "
"et synchronisés."
),
},
{
"id": "sc-28",
"title": "Le Requin Blanc",
"content": (
"Le grand requin blanc est l'un des prédateurs marins les plus redoutés et "
"fascinants. Il peut atteindre 6 mètres de long et peser plus de 2 tonnes. "
"Ses dents triangulaires et dentelées se renouvellent constamment tout au "
"long de sa vie. Il peut détecter une goutte de sang dans 100 litres d'eau "
"grâce à son odorat exceptionnel. Contrairement à sa réputation, il attaque "
"rarement l'homme, préférant les phoques et les otaries."
),
},
{
"id": "sc-29",
"title": "L'Abeille",
"content": (
"Les abeilles jouent un rôle crucial dans la pollinisation de 80% des plantes "
"à fleurs. Une ruche peut contenir jusqu'à 60 000 abeilles organisées en "
"société matriarcale autour de la reine. Les ouvrières communiquent la "
"localisation des fleurs par une danse complexe en forme de huit. Une abeille "
"produit environ 1/12 de cuillère à café de miel dans sa vie. Leur déclin "
"inquiétant menace notre sécurité alimentaire."
),
},
{
"id": "sc-30",
"title": "Le Guépard",
"content": (
"Le guépard est l'animal terrestre le plus rapide, capable d'atteindre 110 "
"km/h en quelques secondes. Contrairement aux autres félins, il ne peut pas "
"rétracter complètement ses griffes, ce qui lui donne une meilleure "
"adhérence. Sa queue longue lui sert de balancier lors des changements de "
"direction. Son corps élancé et ses poumons surdimensionnés sont optimisés "
"pour la course. Il chasse le jour pour éviter les autres prédateurs plus "
"puissants."
),
},
{
"id": "sc-31",
"title": "La Pieuvre",
"content": (
"La pieuvre possède trois cœurs et un sang bleu à base de cuivre. Son "
"intelligence remarquable lui permet de résoudre des problèmes complexes et "
"d'utiliser des outils. Chacun de ses huit bras contient des neurones, lui "
"donnant une forme de pensée décentralisée. Elle peut changer de couleur et "
"de texture instantanément pour se camoufler. Certaines espèces peuvent "
"passer à travers des ouvertures de la taille d'une pièce de monnaie."
),
},
{
"id": "sc-32",
"title": "Le Koala",
"content": (
"Le koala dort jusqu'à 20 heures par jour pour économiser l'énergie "
"nécessaire à digérer l'eucalyptus toxique. Il se nourrit exclusivement de "
"feuilles d'eucalyptus, pouvant distinguer entre 600 espèces différentes. Son "
"système digestif unique contient des bactéries spéciales pour neutraliser "
"les toxines. Les bébés koalas naissent de la taille d'un haricot et restent "
"6 mois dans la poche maternelle. Ils ne boivent presque jamais, tirant l'eau "
"des feuilles qu'ils consomment."
),
},
{
"id": "sc-33",
"title": "Le Gorille des Montagnes",
"content": (
"Le gorille des montagnes est l'un de nos plus proches parents, partageant "
"98% de notre ADN. Les mâles silverback peuvent peser jusqu'à 200 kg et sont "
"d'une force exceptionnelle. Malgré leur puissance, ce sont des animaux "
"pacifiques et végétariens. Ils vivent en groupes familiaux dirigés par un "
"mâle dominant et communiquent par 25 vocalisations différentes. Moins de "
"1000 individus survivent dans les forêts d'Afrique centrale."
),
},
{
"id": "sc-34",
"title": "Le Papillon Monarque",
"content": (
"Le papillon monarque effectue une migration annuelle de 4000 km entre le "
"Canada et le Mexique. Aucun individu ne complète le voyage entier, il faut 4 "
"générations pour accomplir le cycle. Ils utilisent le soleil comme boussole "
"et possèdent une horloge circadienne sophistiquée. Leur couleur orange vif "
"avertit les prédateurs de leur toxicité acquise en mangeant de l'asclépiade. "
"Des millions de papillons se regroupent dans quelques forêts mexicaines en "
"hiver."
),
},
{
"id": "sc-35",
"title": "Le Loup Gris",
"content": (
"Le loup gris vit en meute organisée hiérarchiquement autour d'un couple "
"alpha. Ces chasseurs coopératifs peuvent traquer des proies bien plus "
"grandes qu'eux grâce à leur coordination. Ils communiquent par hurlements "
"pouvant porter jusqu'à 10 km dans les forêts. Un loup peut parcourir 70 km "
"en une nuit à la recherche de nourriture. Leur réintroduction dans certains "
"écosystèmes a démontré leur rôle crucial dans l'équilibre naturel."
),
},
{
"id": "sc-36",
"title": "Le Kangourou Roux",
"content": (
"Le kangourou roux est le plus grand marsupial au monde, pouvant atteindre 2 "
"mètres de haut. Il peut sauter jusqu'à 9 mètres de long et maintenir une "
"vitesse de 50 km/h. Sa queue musclée lui sert de trépied au repos et de "
"balancier en mouvement. Les femelles peuvent retarder le développement d'un "
"embryon si les conditions sont défavorables. Parfaitement adapté au climat "
"aride australien, il peut passer des mois sans boire."
),
},
{
"id": "sc-37",
"title": "La Baleine à Bosse",
"content": (
"Les baleines à bosse produisent des chants complexes pouvant durer 20 "
"minutes et s'entendre à des centaines de kilomètres. Les mâles d'une même "
"région partagent le même chant qui évolue chaque année. Malgré leur masse de "
"30 tonnes, elles peuvent sauter entièrement hors de l'eau. Elles migrent sur "
"25 000 km par an entre zones d'alimentation et de reproduction. Leurs "
"nageoires pectorales sont les plus longues de tous les cétacés."
),
},
{
"id": "sc-38",
"title": "Le Serpent Python",
"content": (
"Le python réticulé peut atteindre 9 mètres de long, ce qui en fait l'un des "
"plus longs serpents. Il tue sa proie par constriction, resserrant son "
"étreinte à chaque expiration de la victime. Ces serpents peuvent passer des "
"mois sans manger après avoir avalé une grande proie. Leurs organes "
"sensoriels thermiques leur permettent de détecter la chaleur corporelle dans "
"l'obscurité. Contrairement aux idées reçues, ils ne peuvent pas avaler un "
"humain adulte."
),
},
{
"id": "sc-39",
"title": "Le Hibou Grand-Duc",
"content": (
"Le hibou grand-duc est le plus grand rapace nocturne d'Europe avec une "
"envergure de 2 mètres. Son vol silencieux est rendu possible par la "
"structure spéciale de ses plumes. Il peut tourner sa tête à 270 degrés grâce "
"à 14 vertèbres cervicales. Sa vision nocturne est 100 fois plus sensible que "
"celle de l'humain. Son hululement puissant peut s'entendre jusqu'à 2 km. Il "
"chasse des proies variées, du mulot au renardeau."
),
},
{
"id": "sc-40",
"title": "Le Paresseux",
"content": (
"Le paresseux se déplace si lentement que des algues poussent sur sa "
"fourrure, lui donnant un camouflage verdâtre. Il descend de son arbre une "
"fois par semaine pour déféquer, moment où il est le plus vulnérable. Son "
"métabolisme est le plus lent de tous les non-hibernants. Il peut tourner sa "
"tête à 270 degrés pour surveiller les prédateurs. Les paresseux passent 90% "
"de leur vie suspendus la tête en bas et peuvent même dormir ainsi."
),
},
{
"id": "sc-41",
"title": "Bœuf Bourguignon",
"content": (
"Le bœuf bourguignon est un plat mijoté emblématique de la cuisine française. "
"Coupez 1,5 kg de bœuf en cubes et faites-les revenir dans du beurre. Ajoutez "
"2 carottes, 2 oignons, 3 gousses d'ail et un bouquet garni. Versez 75 cl de "
"vin rouge de Bourgogne et 25 cl de bouillon. Laissez mijoter 3 heures à feu "
"doux. Ajoutez 200g de champignons et 150g de lardons dorés. Servez avec des "
"pommes de terre vapeur ou des tagliatelles fraîches."
),
},
{
"id": "sc-42",
"title": "Ratatouille Provençale",
"content": (
"Tranchez finement 2 aubergines, 2 courgettes, 2 poivrons, 4 tomates et 1 "
"oignon. Dans une cocotte, faites revenir l'oignon et l'ail dans de l'huile "
"d'olive. Ajoutez les légumes par couches en alternant. Assaisonnez avec du "
"thym, du romarin, sel et poivre. Couvrez et laissez mijoter 40 minutes à feu "
"doux. La ratatouille peut se déguster chaude ou froide, accompagnée de riz "
"ou de pain grillé."
),
},
{
"id": "sc-43",
"title": "Coq au Vin",
"content": (
"Découpez un coq ou un poulet fermier en morceaux. Faites mariner 12 heures "
"dans 75 cl de vin rouge avec carottes, oignons et aromates. Égouttez et "
"faites dorer les morceaux avec 100g de lardons. Flambez au cognac puis "
"ajoutez la marinade filtrée. Laissez mijoter 1h30. Ajoutez des champignons "
"et des petits oignons grelots. Liez la sauce avec du beurre manié. Servez "
"avec des croûtons aillés."
),
},
{
"id": "sc-44",
"title": "Tarte Tatin",
"content": (
"Dans un moule à tarte, faites fondre 100g de beurre avec 100g de sucre pour "
"créer un caramel. Disposez 6 pommes Reinette coupées en quartiers en rosace "
"serrée. Enfournez 30 minutes à 180°C. Recouvrez de pâte feuilletée et "
"enfournez 25 minutes supplémentaires. Laissez tiédir 10 minutes avant de "
"retourner sur un plat. Servez tiède avec de la crème fraîche ou une boule de "
"glace vanille."
),
},
{
"id": "sc-45",
"title": "Bouillabaisse Marseillaise",
"content": (
"Faites suer dans l'huile d'olive 2 oignons, 4 tomates, 4 gousses d'ail et du "
"fenouil. Ajoutez safran, thym, laurier et zeste d'orange. Versez 2 litres de "
"fumet de poisson et portez à ébullition. Ajoutez 2 kg de poissons variés "
"(rascasse, grondin, rouget) coupés en morceaux. Cuisez 15 minutes à gros "
"bouillons. Servez avec des croûtons, de la rouille et du gruyère râpé."
),
},
{
"id": "sc-46",
"title": "Quiche Lorraine",
"content": (
"Garnissez un moule de pâte brisée. Faites revenir 200g de lardons fumés sans "
"matière grasse. Battez 4 œufs avec 30 cl de crème fraîche, sel, poivre et "
"noix de muscade. Disposez les lardons sur la pâte et versez l'appareil. "
"Ajoutez éventuellement 100g de gruyère râpé. Enfournez 35 minutes à 180°C "
"jusqu'à ce que la garniture soit dorée et gonflée. Servez tiède avec une "
"salade verte."
),
},
{
"id": "sc-47",
"title": "Blanquette de Veau",
"content": (
"Coupez 1,2 kg d'épaule de veau en cubes. Blanchissez-les 5 minutes à l'eau "
"bouillante puis rincez. Remettez dans une cocotte avec carottes, oignons "
"piqués de clous de girofle et bouquet garni. Couvrez d'eau et laissez "
"mijoter 1h30. Préparez un roux avec beurre et farine, ajoutez le bouillon "
"filtré. Liez avec 2 jaunes d'œufs et 20 cl de crème. Ajoutez champignons et "
"oignons grelots. Servez avec du riz."
),
},
{
"id": "sc-48",
"title": "Crêpes Bretonnes",
"content": (
"Mélangez 250g de farine, 4 œufs, 50 cl de lait et une pincée de sel. Ajoutez "
"50g de beurre fondu et laissez reposer 1 heure. Huilez légèrement une poêle "
"chaude et versez une louche de pâte. Faites cuire 2 minutes de chaque côté "
"jusqu'à ce que la crêpe soit dorée. Garnissez de sucre, confiture, chocolat "
"ou jambon-fromage selon vos envies. Servez immédiatement."
),
},
{
"id": "sc-49",
"title": "Cassoulet Toulousain",
"content": (
"Faites tremper 500g de haricots blancs 12 heures. Cuisez-les 1 heure avec "
"carottes, oignons et bouquet garni. Faites revenir 4 cuisses de canard "
"confites, 400g de saucisse de Toulouse et 200g de poitrine fumée. Mélangez "
"haricots et viandes dans une cocotte avec la graisse de canard. Couvrez de "
"chapelure et enfournez 1 heure à 160°C. Cassez la croûte plusieurs fois "
"pendant la cuisson."
),
},
{
"id": "sc-50",
"title": "Soufflé au Fromage",
"content": (
"Préparez une béchamel avec 40g de beurre, 40g de farine et 25 cl de lait. "
"Hors du feu, incorporez 150g de gruyère râpé et 4 jaunes d'œufs. Montez 5 "
"blancs en neige ferme avec une pincée de sel. Incorporez délicatement les "
"blancs à la préparation. Versez dans des ramequins beurrés et farinés. "
"Enfournez 20 minutes à 180°C sans ouvrir le four. Servez immédiatement."
),
},
{
"id": "sc-51",
"title": "Pot-au-feu",
"content": (
"Dans une grande marmite, placez 1,5 kg de viande de bœuf (gîte, paleron, "
"plat de côtes). Couvrez d'eau froide et portez à ébullition en écumant. "
"Ajoutez 4 carottes, 4 poireaux, 2 navets, 2 oignons piqués de clous de "
"girofle et un bouquet garni. Laissez mijoter 3 heures à feu doux. Servez le "
"bouillon en entrée puis la viande et les légumes avec cornichons, moutarde "
"et gros sel."
),
},
{
"id": "sc-52",
"title": "Mousse au Chocolat",
"content": (
"Faites fondre 200g de chocolat noir au bain-marie. Séparez 6 œufs et "
"incorporez les jaunes au chocolat fondu tiède. Montez les blancs en neige "
"ferme avec une pincée de sel et 20g de sucre. Incorporez délicatement les "
"blancs au chocolat en trois fois avec une spatule. Répartissez dans des "
"verrines et réfrigérez 4 heures minimum. Décorez de copeaux de chocolat ou "
"de chantilly."
),
},
{
"id": "sc-53",
"title": "Gratin Dauphinois",
"content": (
"Épluchez et émincez finement 1,5 kg de pommes de terre. Frottez un plat à "
"gratin avec une gousse d'ail. Disposez les pommes de terre en couches en "
"assaisonnant de sel, poivre et noix de muscade. Mélangez 50 cl de crème avec "
"25 cl de lait et versez sur les pommes de terre. Ajoutez quelques noisettes "
"de beurre. Enfournez 1h15 à 160°C jusqu'à obtenir une croûte dorée."
),
},
{
"id": "sc-54",
"title": "Salade de légumes",
"content": (
"Disposez sur un plat 4 tomates en quartiers, 1 concombre émincé, 1 poivron "
"coupé, 200g de haricots verts cuits, 4 œufs durs, 100g d'olives noires et 8 "
"filets d'anchois. Préparez une vinaigrette avec huile d'olive, vinaigre de "
"vin, moutarde, ail et basilic. Arrosez généreusement et servez frais. "
"Certains ajoutent des artichauts ou des fèves."
),
},
{
"id": "sc-55",
"title": "Tarte au Citron Meringuée",
"content": (
"Garnissez un moule de pâte sablée et faites-la cuire à blanc 15 minutes. "
"Préparez une crème avec 4 jaunes d'œufs, 150g de sucre, le zeste et jus de 3 "
"citrons. Cuisez à feu doux en remuant jusqu'à épaississement. Versez sur la "
"pâte. Montez 4 blancs en neige avec 100g de sucre. Couvrez la tarte de "
"meringue en formant des pointes. Enfournez 10 minutes à 150°C pour dorer."
),
},
{
"id": "sc-56",
"title": "Magret de Canard aux Figues",
"content": (
"Quadrillez la peau de 2 magrets sans entamer la chair. Salez et poivrez. "
"Posez-les côté peau dans une poêle froide. Faites cuire 7 minutes puis "
"retournez pour 4 minutes (rosé). Réservez au chaud. Dans la graisse, faites "
"revenir 8 figues coupées en deux avec 2 cuillères de miel et un trait de "
"vinaigre balsamique. Tranchez les magrets et nappez de sauce aux figues."
),
},
{
"id": "sc-57",
"title": "Clafoutis aux Cerises",
"content": (
"Disposez 500g de cerises lavées et équeutées (traditionnellement non "
"dénoyautées) dans un plat beurré. Battez 4 œufs avec 100g de sucre jusqu'à "
"ce que le mélange blanchisse. Ajoutez 100g de farine, 50 cl de lait et une "
"pincée de sel. Versez l'appareil sur les cerises. Enfournez 45 minutes à "
"180°C. Saupoudrez de sucre glace et servez tiède ou froid."
),
},
{
"id": "sc-58",
"title": "Fondue Savoyarde",
"content": (
"Frottez un caquelon avec une gousse d'ail coupée. Versez 15 cl de vin blanc "
"sec et faites chauffer. Ajoutez 400g de comté, 400g de beaufort et 200g de "
"reblochon râpés. Remuez en forme de 8 jusqu'à ce que le fromage fonde. "
"Ajoutez 5 cl de kirsch et du poivre. Maintenez au chaud sur un réchaud. "
"Trempez des cubes de pain rassis piqués sur des fourchettes."
),
},
{
"id": "sc-59",
"title": "Crème Brûlée",
"content": (
"Faites chauffer 50 cl de crème avec une gousse de vanille fendue. Battez 6 "
"jaunes d'œufs avec 100g de sucre. Versez la crème chaude en remuant puis "
"filtrez. Répartissez dans des ramequins. Cuisez au bain-marie 40 minutes à "
"150°C. Réfrigérez 4 heures. Saupoudrez généreusement de sucre et caramélisez "
"au chalumeau ou sous le gril. Laissez durcir 2 minutes avant de servir."
),
},
{
"id": "sc-60",
"title": "Tartare de Bœuf",
"content": (
"Hachez finement 600g de filet de bœuf bien frais au couteau. Ajoutez 2 "
"échalotes ciselées, 2 cuillères de câpres, 4 cornichons hachés, persil et "
"ciboulette. Assaisonnez avec moutarde, sauce Worcestershire, Tabasco, sel et "
"poivre. Incorporez 2 jaunes d'œufs et un filet d'huile d'olive. Formez des "
"dômes et servez avec des frites maison et une salade verte. Se prépare au "
"dernier moment."
),
},
{
"id": "sc-61",
"title": "La Musique",
"content": (
"La musique est l'art d'organiser les sons dans le temps selon le rythme, la "
"mélodie et l'harmonie. Présente dans toutes les cultures, elle accompagne "
"les rites, les émotions et les récits humains depuis la préhistoire. Des "
"modes antiques grecs au contrepoint baroque, du jazz à l'électro, elle "
"évolue sans cesse. Les compositeurs explorent les timbres et les structures "
"tandis que limprovisation garde la spontanéité vivante. La musique relie "
"mathématique, émotion et mouvement."
),
},
{
"id": "sc-62",
"title": "Le Cinéma",
"content": (
"Le cinéma est l'art de raconter des histoires par le mouvement des images et "
"le son. Né à la fin du XIXe siècle avec les frères Lumière, il a rapidement "
"fusionné technique et poésie. Le montage, la lumière et la mise en scène en "
"font un art total mêlant littérature, théâtre et musique. Du muet de Chaplin "
"au cinéma numérique, chaque époque invente un nouveau langage visuel. Le "
"cinéma explore la mémoire, les rêves et la condition humaine à travers "
"l’écran."
),
},
{
"id": "sc-63",
"title": "La Danse",
"content": (
"La danse est lart du mouvement du corps dans lespace et le temps, souvent "
"accompagné de musique. Elle exprime des émotions, raconte des histoires ou "
"célèbre des rites. Des danses tribales aux ballets classiques, des danses "
"contemporaines au hip-hop, chaque culture invente ses gestes et son rythme. "
"La chorégraphie unit discipline et liberté, le corps devenant un instrument "
"expressif. La danse relie énergie, esthétique et communication non verbale."
),
},
{
"id": "sc-64",
"title": "La Peinture à l'Huile",
"content": (
"La peinture à l'huile est une technique artistique utilisant des pigments "
"mélangés à de l'huile siccative, généralement de lin. Inventée au XVe siècle "
"et perfectionnée par les maîtres flamands comme Van Eyck, elle permet des "
"glacis subtils et des dégradés lumineux. Le temps de séchage lent offre la "
"possibilité de travailler les transitions et les détails. Les grands maîtres "
"comme Rembrandt, Vermeer et plus tard les impressionnistes ont exploité ses "
"possibilités. Cette technique reste aujourd'hui la plus prisée pour la "
"peinture de chevalet."
),
},
{
"id": "sc-65",
"title": "La Sculpture sur Pierre",
"content": (
"La sculpture sur pierre est l'un des arts les plus anciens de l'humanité, "
"remontant à la préhistoire. Le sculpteur taille directement dans le marbre, "
"le granit ou le calcaire avec des ciseaux et des masses. Michel-Ange "
"considérait que la statue existait déjà dans le bloc, il suffisait de "
"libérer la forme. Cette technique soustractive ne pardonne pas l'erreur. Les "
"œuvres comme le David ou la Pietà démontrent la capacité de donner vie et "
"émotion à la pierre froide."
),
},
{
"id": "sc-66",
"title": "La Calligraphie",
"content": (
"La calligraphie est l'art de former les lettres avec beauté et harmonie. En "
"Occident, les moines copistes médiévaux ont perfectionné l'onciale et la "
"gothique. En Asie, la calligraphie chinoise et japonaise est considérée "
"comme la forme d'art la plus pure, où chaque trait exprime l'énergie et "
"l'esprit de l'artiste. L'outil traditionnel est le pinceau ou le calame. La "
"maîtrise nécessite des années de pratique pour contrôler la pression, la "
"vitesse et le rythme."
),
},
{
"id": "sc-67",
"title": "La Photographie",
"content": (
"La photographie transforme la capture d'images en expression créative depuis "
"le XIXe siècle. Des pionniers comme Ansel Adams et Henri Cartier-Bresson ont "
"élevé le médium au rang d'art majeur. La composition, la lumière, le cadrage "
"et le moment décisif sont essentiels. Le passage au numérique a ouvert de "
"nouvelles possibilités de post-traitement. La photographie d'art explore "
"tous les genres : portrait, paysage, abstrait, documentaire et conceptuel."
),
},
{
"id": "sc-68",
"title": "La Danse Contemporaine",
"content": (
"La danse contemporaine émerge au XXe siècle comme rupture avec le ballet "
"classique. Des chorégraphes comme Martha Graham, Merce Cunningham et Pina "
"Bausch explorent de nouveaux langages corporels. Cette forme privilégie "
"l'expression émotionnelle, la liberté de mouvement et l'improvisation. Le "
"corps devient un outil de questionnement social et politique. Les spectacles "
"intègrent souvent d'autres disciplines comme la vidéo, le théâtre et la "
"musique expérimentale."
),
},
{
"id": "sc-69",
"title": "L'Origami",
"content": (
"L'origami est l'art japonais du pliage de papier, transformant une feuille "
"plane en sculpture tridimensionnelle sans couper ni coller. Pratiqué depuis "
"le VIe siècle au Japon, il était d'abord réservé aux cérémonies religieuses. "
"Les modèles traditionnels incluent la grue (symbole de paix), la grenouille "
"et la fleur. L'origami moderne explore la complexité mathématique avec des "
"créations hyperréalistes. Cette discipline développe patience, précision et "
"compréhension spatiale."
),
},
{
"id": "sc-70",
"title": "La Mosaïque",
"content": (
"La mosaïque assemble de petits fragments colorés (tesselles) de pierre, "
"céramique ou verre pour créer des images et motifs. Les Romains et Byzantins "
"ont porté cet art à son apogée avec les splendeurs de Ravenne et de "
"Constantinople. Chaque tesselle est posée individuellement sur un support "
"avec du mortier. Les jeux de lumière sur les tesselles de verre créent des "
"effets lumineux uniques. Gaudi a réinventé la mosaïque moderne avec le "
"trencadis au Parc Güell."
),
},
{
"id": "sc-71",
"title": "Le Théâtre",
"content": (
"Le théâtre est à la fois un art de la représentation et un lieu de rencontre "
"sociale. Né dans l'Antiquité grecque avec les tragédies d'Eschyle et "
"Sophocle, il explore les grandes questions humaines. Le Moyen Âge voit "
"l'essor des mystères religieux, tandis que la Renaissance célèbre "
"Shakespeare et Molière. Le théâtre moderne expérimente avec le réalisme, "
"l'absurde et le théâtre de l'opprimé. Il combine texte, jeu d'acteur, décor "
"et lumière pour créer une expérience immersive."
),
},
{
"id": "sc-72",
"title": "Le Vitrail",
"content": (
"Le vitrail assemble des morceaux de verre coloré maintenus par des baguettes "
"de plomb pour créer des compositions lumineuses. Au Moyen Âge, les "
"cathédrales gothiques comme Chartres transforment la lumière divine en "
"récits bibliques. Les maîtres verriers maîtrisent la chimie des oxydes "
"métalliques pour obtenir des couleurs intenses. Chaque pièce est taillée "
"selon le carton préparatoire puis sertie. Le XXe siècle voit Chagall et "
"Soulages réinventer cet art millénaire."
),
},
{
"id": "sc-73",
"title": "La Gravure",
"content": (
"La gravure est une technique d'impression où l'artiste incise une matrice "
"(bois, métal, pierre) pour créer des estampes multiples. La xylogravie "
"(bois) est la plus ancienne, utilisée par Dürer et les estampes japonaises "
"ukiyo-e. La taille-douce (métal) comprend l'eau-forte, l'aquatinte et le "
"burin, prisées par Rembrandt et Goya. La lithographie, inventée en 1796, "
"permet des nuances subtiles exploitées par Toulouse-Lautrec. Chaque tirage "
"est numéroté et signé par l'artiste."
),
},
]
@@ -0,0 +1,4 @@
"""
evaluation dataset for full_text capabilities.
evaluation should be good with embeddings disabled
"""
@@ -0,0 +1,10 @@
"""document data for full text evaluation"""
from ..corpus.simple_corpus import documents as simple_corpus_documents
documents = [
*simple_corpus_documents,
{"id": "ft-1", "title": "L'éléphant", "content": "L'éléphant s'est échappé"},
{"id": "ft-2", "title": "Foot", "content": "Le foot est un sport populaire"},
{"id": "ft-3", "title": "Il va courir", "content": "Il va courir"},
]
@@ -0,0 +1,26 @@
"""Queries and expected document IDs for evaluation in French language."""
queries = [
{
"q": "elephant",
"expected_document_ids": ["sc-23", "ft-1"],
},
{
"q": "courir",
"expected_document_ids": ["ft-3"],
},
{
# test "football" -> "foot"
"q": "football",
"expected_document_ids": ["ft-2"],
},
{ # test partial word matching
"q": "couri",
"expected_document_ids": ["ft-3"],
},
{
# test fuzzy matching with ngrams
"q": "courrir",
"expected_document_ids": ["ft-3"],
},
]
@@ -0,0 +1 @@
"""base evaluation datasets for semantic capabilities."""
@@ -0,0 +1,5 @@
"""Documents for semantic evaluation."""
from ..corpus.simple_corpus import documents as simple_corpus_documents
documents = simple_corpus_documents
@@ -0,0 +1,32 @@
"""Queries and expected document IDs for evaluation in French language."""
queries = [
{
"q": "cours d'histoire de l'antiquité",
"expected_document_ids": ["sc-5", "sc-8"],
},
{
"q": "recette salée végétarienne",
"expected_document_ids": ["sc-42", "sc-54", "sc-58"],
},
{
"q": "art dramatique",
"expected_document_ids": ["sc-71"],
},
{
"q": "art de bouger son corps",
"expected_document_ids": ["sc-63", "sc-68"],
},
{
"q": "mammifères aquatiques",
"expected_document_ids": ["sc-22", "sc-37"],
},
{
"q": "insectes pollinisateurs",
"expected_document_ids": ["sc-29"],
},
{
"q": "prédateur félin",
"expected_document_ids": ["sc-21", "sc-30"],
},
]
@@ -0,0 +1 @@
"""evaluation dataset tests"""
@@ -0,0 +1,3 @@
"""document data"""
documents = [{"id": 1, "title": "document", "content": "a document"}]
@@ -1 +0,0 @@
a content
@@ -1,3 +1,5 @@
"""Queries and expected document IDs for test evaluation."""
queries = [
{
"q": "a query",
@@ -1 +0,0 @@
La conquête spatiale débute en 1957 avec le satellite soviétique Spoutnik. Youri Gagarine devient le premier homme dans l'espace en 1961. Les Américains répondent avec le programme Apollo : Neil Armstrong marche sur la Lune en 1969. La Station Spatiale Internationale symbolise la coopération internationale depuis 1998. Aujourd'hui, les ambitions incluent Mars, et des entreprises privées comme SpaceX révolutionnent l'accès à l'espace.
@@ -1 +0,0 @@
Les métropoles mondiales comme New York, Londres, Tokyo et Paris concentrent pouvoir économique, politique et culturel. Elles abritent sièges sociaux des multinationales, bourses financières et institutions internationales. Ces villes sont des nœuds de transport et communication globaux. Elles attirent flux migratoires et talents créant diversité mais aussi gentrification. Les mégapoles du Sud (Mumbai, São Paulo) connaissent une urbanisation rapide avec des défis d'infrastructures.
@@ -1 +0,0 @@
La Seconde Guerre mondiale (1939-1945) est le conflit le plus meurtrier de l'histoire avec 60 millions de morts. Hitler envahit la Pologne en septembre 1939, déclenchant la guerre. La France capitule en 1940 tandis que le Royaume-Uni résiste. L'Allemagne attaque l'URSS en 1941 et le Japon Pearl Harbor. Le débarquement en Normandie en 1944 libère l'Europe. La Shoah extermine 6 millions de Juifs. Le conflit se termine avec les bombes atomiques sur Hiroshima et Nagasaki.
@@ -1 +0,0 @@
Le développement durable vise à concilier croissance économique, protection environnementale et équité sociale. Défini au Sommet de Rio en 1992, il répond aux besoins présents sans compromettre ceux des générations futures. Les 17 Objectifs de Développement Durable de l'ONU incluent éradication de la pauvreté, éducation, égalité des genres et action climatique. Les énergies renouvelables, l'économie circulaire et la consommation responsable sont des leviers essentiels.
@@ -1 +0,0 @@
La Chine est devenue la deuxième économie mondiale grâce aux réformes de Deng Xiaoping en 1978. L'ouverture au capitalisme maintient le régime communiste du Parti unique. Le pays est 'l'usine du monde' avec des zones économiques spéciales. Les nouvelles routes de la soie étendent son influence internationale. Avec 1,4 milliard d'habitants, la Chine fait face à des défis environnementaux, démographiques et sociaux majeurs.
@@ -1 +0,0 @@
Les grandes découvertes (XVe-XVIe siècles) voient les Européens explorer le monde. Christophe Colomb atteint l'Amérique en 1492, croyant trouver les Indes. Vasco de Gama ouvre la route des Indes en 1498. Magellan réalise le premier tour du monde en 1522. Ces expéditions sont motivées par la recherche d'épices, d'or et l'évangélisation. Elles inaugurent la colonisation européenne et l'échange colombien qui transforment les civilisations.
@@ -1 +0,0 @@
L'Union européenne naît du désir de paix après 1945. Le traité de Rome en 1957 crée la CEE avec six membres fondateurs. L'UE compte aujourd'hui 27 États après le Brexit. Le marché unique permet libre circulation des biens, services, capitaux et personnes. L'euro est adopté par 20 pays. L'UE fait face à des défis : crise migratoire, euroscepticisme et divergences économiques entre Nord et Sud.
@@ -1 +0,0 @@
La révolution industrielle débute en Angleterre au XVIIIe siècle avec la machine à vapeur de Watt. L'industrie textile mécanisée précède la métallurgie et les chemins de fer. Le charbon et la vapeur fournissent l'énergie nécessaire. L'urbanisation s'accélère avec l'exode rural vers les usines. Les conditions ouvrières misérables suscitent les mouvements sociaux et le syndicalisme. Cette révolution transforme radicalement l'économie et la société européennes.
@@ -1 +0,0 @@
Les inégalités Nord-Sud opposent pays développés et pays en développement. L'Indice de Développement Humain mesure richesse, éducation et santé. Les pays du Nord concentrent richesses et technologies tandis que le Sud subit pauvreté et dépendance économique. L'héritage colonial, l'endettement et les termes de l'échange inégaux perpétuent ces écarts. Les pays émergents (BRICS) remettent en question cette division binaire.
@@ -1 +0,0 @@
Le Siècle des Lumières (XVIIIe siècle) est un mouvement intellectuel européen promouvant raison, science et progrès. Voltaire critique l'intolérance religieuse, Montesquieu théorise la séparation des pouvoirs, Rousseau défend la souveraineté populaire. L'Encyclopédie de Diderot et d'Alembert diffuse les savoirs. Ces philosophes remettent en cause l'absolutisme et les privilèges. Leurs idées inspirent les révolutions américaine et française.
@@ -1 +0,0 @@
La Révolution française débute le 14 juillet 1789 avec la prise de la Bastille, symbole de l'absolutisme royal. Les causes sont multiples : crise financière, famine, influence des Lumières et inégalités sociales criantes. L'Assemblée Constituante abolit les privilèges le 4 août et adopte la Déclaration des Droits de l'Homme le 26 août. Le roi Louis XVI est exécuté en 1793 après la proclamation de la République. Cette révolution transforme profondément la France et influence l'Europe entière.
@@ -1 +0,0 @@
Les ressources énergétiques sont inégalement réparties sur la planète. Les énergies fossiles (pétrole, gaz, charbon) dominent mais sont non renouvelables et polluantes. Le Moyen-Orient concentre 48% des réserves pétrolières mondiales. La Russie utilise le gaz comme arme géopolitique. Les énergies renouvelables (solaire, éolien, hydraulique) progressent face au réchauffement climatique. La transition énergétique est un défi majeur du XXIe siècle pour assurer souveraineté et durabilité.
@@ -1 +0,0 @@
Le lion est le plus grand félin d'Afrique et vit en groupe social appelé troupe. Les mâles se distinguent par leur majestueuse crinière qui protège leur cou lors des combats. Les lions chassent principalement au crépuscule, les lionnes étant les principales chasseuses. Ils peuvent rugir jusqu'à 8 kilomètres de distance pour marquer leur territoire. Malheureusement, leur population a diminué de 43% en 20 ans.
@@ -1 +0,0 @@
Les dauphins sont parmi les animaux les plus intelligents de la planète. Ils communiquent par des clics et sifflements complexes, chaque individu possédant sa propre signature sonore. Vivant en groupes sociaux sophistiqués, ils chassent en coordination et s'entraident. Les dauphins peuvent nager jusqu'à 30 km/h et plonger à 300 mètres de profondeur. Leur cerveau possède plus de circonvolutions que celui de l'homme.
@@ -1 +0,0 @@
L'éléphant d'Asie est légèrement plus petit que son cousin africain mais tout aussi majestueux. Ces herbivores peuvent consommer jusqu'à 150 kg de végétation par jour et boire 140 litres d'eau. Leur trompe contient 40 000 muscles et leur sert à manger, boire, se laver et communiquer. Les éléphants vivent en matriarcat dirigé par la femelle la plus âgée. Leur mémoire exceptionnelle leur permet de se souvenir des points d'eau sur de vastes territoires.
@@ -1 +0,0 @@
Le colibri est le plus petit oiseau du monde, certaines espèces ne pesant que 2 grammes. Ses ailes battent jusqu'à 80 fois par seconde, lui permettant de faire du vol stationnaire et même de voler en arrière. Son métabolisme est si rapide qu'il doit manger jusqu'à deux fois son poids en nectar quotidiennement. Le colibri peut visiter 1000 fleurs par jour. Ses couleurs iridescentes changent selon l'angle de la lumière.
@@ -1 +0,0 @@
L'ours polaire est le plus grand carnivore terrestre, pouvant peser jusqu'à 800 kg. Sa fourrure blanche et sa peau noire lui permettent d'absorber la chaleur du soleil. Son odorat est si développé qu'il peut détecter un phoque sous 1 mètre de glace à 1,5 km. Le réchauffement climatique menace gravement son habitat.
@@ -1 +0,0 @@
Le caméléon possède la capacité extraordinaire de changer de couleur selon son humeur, la température et la communication sociale. Ses yeux peuvent bouger indépendamment l'un de l'autre à 360 degrés. Sa langue peut s'étendre jusqu'à deux fois la longueur de son corps pour capturer des insectes. Certaines espèces mesurent seulement 3 cm tandis que d'autres atteignent 70 cm. Ils se déplacent lentement et de manière saccadée pour imiter le mouvement des feuilles.
@@ -1 +0,0 @@
Le manchot empereur survit aux conditions les plus extrêmes de la planète en Antarctique. Les mâles couvent l'œuf unique pendant 64 jours dans des températures atteignant -40°C sans manger. Ils se regroupent en tortue pour se protéger du froid, tournant régulièrement pour partager la chaleur. Ces oiseaux peuvent plonger jusqu'à 500 mètres de profondeur et retenir leur respiration 22 minutes. Leur parade nuptiale comprend des chants complexes et synchronisés.
@@ -1 +0,0 @@
Le grand requin blanc est l'un des prédateurs marins les plus redoutés et fascinants. Il peut atteindre 6 mètres de long et peser plus de 2 tonnes. Ses dents triangulaires et dentelées se renouvellent constamment tout au long de sa vie. Il peut détecter une goutte de sang dans 100 litres d'eau grâce à son odorat exceptionnel. Contrairement à sa réputation, il attaque rarement l'homme, préférant les phoques et les otaries.
@@ -1 +0,0 @@
Les abeilles jouent un rôle crucial dans la pollinisation de 80% des plantes à fleurs. Une ruche peut contenir jusqu'à 60 000 abeilles organisées en société matriarcale autour de la reine. Les ouvrières communiquent la localisation des fleurs par une danse complexe en forme de huit. Une abeille produit environ 1/12 de cuillère à café de miel dans sa vie. Leur déclin inquiétant menace notre sécurité alimentaire.
@@ -1 +0,0 @@
La Première Guerre mondiale (1914-1918) oppose les Alliés (France, Royaume-Uni, Russie) aux Empires centraux (Allemagne, Autriche-Hongrie). L'assassinat de l'archiduc François-Ferdinand à Sarajevo déclenche le conflit par le jeu des alliances. La guerre de tranchées caractérise le front occidental avec Verdun et la Somme. Les nouvelles armes (gaz, tanks, aviation) causent 18 millions de morts. Le traité de Versailles en 1919 redessine la carte de l'Europe.
@@ -1 +0,0 @@
Le guépard est l'animal terrestre le plus rapide, capable d'atteindre 110 km/h en quelques secondes. Contrairement aux autres félins, il ne peut pas rétracter complètement ses griffes, ce qui lui donne une meilleure adhérence. Sa queue longue lui sert de balancier lors des changements de direction. Son corps élancé et ses poumons surdimensionnés sont optimisés pour la course. Il chasse le jour pour éviter les autres prédateurs plus puissants.
@@ -1 +0,0 @@
La pieuvre possède trois cœurs et un sang bleu à base de cuivre. Son intelligence remarquable lui permet de résoudre des problèmes complexes et d'utiliser des outils. Chacun de ses huit bras contient des neurones, lui donnant une forme de pensée décentralisée. Elle peut changer de couleur et de texture instantanément pour se camoufler. Certaines espèces peuvent passer à travers des ouvertures de la taille d'une pièce de monnaie.
@@ -1 +0,0 @@
Le koala dort jusqu'à 20 heures par jour pour économiser l'énergie nécessaire à digérer l'eucalyptus toxique. Il se nourrit exclusivement de feuilles d'eucalyptus, pouvant distinguer entre 600 espèces différentes. Son système digestif unique contient des bactéries spéciales pour neutraliser les toxines. Les bébés koalas naissent de la taille d'un haricot et restent 6 mois dans la poche maternelle. Ils ne boivent presque jamais, tirant l'eau des feuilles qu'ils consomment.
@@ -1 +0,0 @@
Le gorille des montagnes est l'un de nos plus proches parents, partageant 98% de notre ADN. Les mâles silverback peuvent peser jusqu'à 200 kg et sont d'une force exceptionnelle. Malgré leur puissance, ce sont des animaux pacifiques et végétariens. Ils vivent en groupes familiaux dirigés par un mâle dominant et communiquent par 25 vocalisations différentes. Moins de 1000 individus survivent dans les forêts d'Afrique centrale.
@@ -1 +0,0 @@
Le papillon monarque effectue une migration annuelle de 4000 km entre le Canada et le Mexique. Aucun individu ne complète le voyage entier, il faut 4 générations pour accomplir le cycle. Ils utilisent le soleil comme boussole et possèdent une horloge circadienne sophistiquée. Leur couleur orange vif avertit les prédateurs de leur toxicité acquise en mangeant de l'asclépiade. Des millions de papillons se regroupent dans quelques forêts mexicaines en hiver.
@@ -1 +0,0 @@
Le loup gris vit en meute organisée hiérarchiquement autour d'un couple alpha. Ces chasseurs coopératifs peuvent traquer des proies bien plus grandes qu'eux grâce à leur coordination. Ils communiquent par hurlements pouvant porter jusqu'à 10 km dans les forêts. Un loup peut parcourir 70 km en une nuit à la recherche de nourriture. Leur réintroduction dans certains écosystèmes a démontré leur rôle crucial dans l'équilibre naturel.
@@ -1 +0,0 @@
Le kangourou roux est le plus grand marsupial au monde, pouvant atteindre 2 mètres de haut. Il peut sauter jusqu'à 9 mètres de long et maintenir une vitesse de 50 km/h. Sa queue musclée lui sert de trépied au repos et de balancier en mouvement. Les femelles peuvent retarder le développement d'un embryon si les conditions sont défavorables. Parfaitement adapté au climat aride australien, il peut passer des mois sans boire.
@@ -1 +0,0 @@
Les baleines à bosse produisent des chants complexes pouvant durer 20 minutes et s'entendre à des centaines de kilomètres. Les mâles d'une même région partagent le même chant qui évolue chaque année. Malgré leur masse de 30 tonnes, elles peuvent sauter entièrement hors de l'eau. Elles migrent sur 25 000 km par an entre zones d'alimentation et de reproduction. Leurs nageoires pectorales sont les plus longues de tous les cétacés.
@@ -1 +0,0 @@
Le python réticulé peut atteindre 9 mètres de long, ce qui en fait l'un des plus longs serpents. Il tue sa proie par constriction, resserrant son étreinte à chaque expiration de la victime. Ces serpents peuvent passer des mois sans manger après avoir avalé une grande proie. Leurs organes sensoriels thermiques leur permettent de détecter la chaleur corporelle dans l'obscurité. Contrairement aux idées reçues, ils ne peuvent pas avaler un humain adulte.
@@ -1 +0,0 @@
Le hibou grand-duc est le plus grand rapace nocturne d'Europe avec une envergure de 2 mètres. Son vol silencieux est rendu possible par la structure spéciale de ses plumes. Il peut tourner sa tête à 270 degrés grâce à 14 vertèbres cervicales. Sa vision nocturne est 100 fois plus sensible que celle de l'humain. Son hululement puissant peut s'entendre jusqu'à 2 km. Il chasse des proies variées, du mulot au renardeau.
@@ -1 +0,0 @@
La décolonisation de l'Afrique s'accélère après 1945, affaiblie par la Seconde Guerre mondiale. Le Ghana obtient son indépendance en 1957, suivi de 17 pays en 1960, 'l'année de l'Afrique'. Le processus varie : négociation pacifique (Afrique francophone) ou luttes armées (Algérie, Kenya). Les frontières héritées de la colonisation créent des tensions ethniques. Les nouveaux États font face à des défis économiques et politiques majeurs.
@@ -1 +0,0 @@
Le paresseux se déplace si lentement que des algues poussent sur sa fourrure, lui donnant un camouflage verdâtre. Il descend de son arbre une fois par semaine pour déféquer, moment où il est le plus vulnérable. Son métabolisme est le plus lent de tous les non-hibernants. Il peut tourner sa tête à 270 degrés pour surveiller les prédateurs. Les paresseux passent 90% de leur vie suspendus la tête en bas et peuvent même dormir ainsi.
@@ -1 +0,0 @@
Le bœuf bourguignon est un plat mijoté emblématique de la cuisine française. Coupez 1,5 kg de bœuf en cubes et faites-les revenir dans du beurre. Ajoutez 2 carottes, 2 oignons, 3 gousses d'ail et un bouquet garni. Versez 75 cl de vin rouge de Bourgogne et 25 cl de bouillon. Laissez mijoter 3 heures à feu doux. Ajoutez 200g de champignons et 150g de lardons dorés. Servez avec des pommes de terre vapeur ou des tagliatelles fraîches.
@@ -1 +0,0 @@
Tranchez finement 2 aubergines, 2 courgettes, 2 poivrons, 4 tomates et 1 oignon. Dans une cocotte, faites revenir l'oignon et l'ail dans de l'huile d'olive. Ajoutez les légumes par couches en alternant. Assaisonnez avec du thym, du romarin, sel et poivre. Couvrez et laissez mijoter 40 minutes à feu doux. La ratatouille peut se déguster chaude ou froide, accompagnée de riz ou de pain grillé.
@@ -1 +0,0 @@
Découpez un coq ou un poulet fermier en morceaux. Faites mariner 12 heures dans 75 cl de vin rouge avec carottes, oignons et aromates. Égouttez et faites dorer les morceaux avec 100g de lardons. Flambez au cognac puis ajoutez la marinade filtrée. Laissez mijoter 1h30. Ajoutez des champignons et des petits oignons grelots. Liez la sauce avec du beurre manié. Servez avec des croûtons aillés.
@@ -1 +0,0 @@
Dans un moule à tarte, faites fondre 100g de beurre avec 100g de sucre pour créer un caramel. Disposez 6 pommes Reinette coupées en quartiers en rosace serrée. Enfournez 30 minutes à 180°C. Recouvrez de pâte feuilletée et enfournez 25 minutes supplémentaires. Laissez tiédir 10 minutes avant de retourner sur un plat. Servez tiède avec de la crème fraîche ou une boule de glace vanille.
@@ -1 +0,0 @@
Faites suer dans l'huile d'olive 2 oignons, 4 tomates, 4 gousses d'ail et du fenouil. Ajoutez safran, thym, laurier et zeste d'orange. Versez 2 litres de fumet de poisson et portez à ébullition. Ajoutez 2 kg de poissons variés (rascasse, grondin, rouget) coupés en morceaux. Cuisez 15 minutes à gros bouillons. Servez avec des croûtons, de la rouille et du gruyère râpé.
@@ -1 +0,0 @@
Garnissez un moule de pâte brisée. Faites revenir 200g de lardons fumés sans matière grasse. Battez 4 œufs avec 30 cl de crème fraîche, sel, poivre et noix de muscade. Disposez les lardons sur la pâte et versez l'appareil. Ajoutez éventuellement 100g de gruyère râpé. Enfournez 35 minutes à 180°C jusqu'à ce que la garniture soit dorée et gonflée. Servez tiède avec une salade verte.
@@ -1 +0,0 @@
Coupez 1,2 kg d'épaule de veau en cubes. Blanchissez-les 5 minutes à l'eau bouillante puis rincez. Remettez dans une cocotte avec carottes, oignons piqués de clous de girofle et bouquet garni. Couvrez d'eau et laissez mijoter 1h30. Préparez un roux avec beurre et farine, ajoutez le bouillon filtré. Liez avec 2 jaunes d'œufs et 20 cl de crème. Ajoutez champignons et oignons grelots. Servez avec du riz.
@@ -1 +0,0 @@
Mélangez 250g de farine, 4 œufs, 50 cl de lait et une pincée de sel. Ajoutez 50g de beurre fondu et laissez reposer 1 heure. Huilez légèrement une poêle chaude et versez une louche de pâte. Faites cuire 2 minutes de chaque côté jusqu'à ce que la crêpe soit dorée. Garnissez de sucre, confiture, chocolat ou jambon-fromage selon vos envies. Servez immédiatement.
@@ -1 +0,0 @@
Faites tremper 500g de haricots blancs 12 heures. Cuisez-les 1 heure avec carottes, oignons et bouquet garni. Faites revenir 4 cuisses de canard confites, 400g de saucisse de Toulouse et 200g de poitrine fumée. Mélangez haricots et viandes dans une cocotte avec la graisse de canard. Couvrez de chapelure et enfournez 1 heure à 160°C. Cassez la croûte plusieurs fois pendant la cuisson.
@@ -1 +0,0 @@
Le réchauffement climatique désigne l'augmentation de la température moyenne terrestre causée par les émissions de gaz à effet de serre. Depuis l'ère industrielle, la température a augmenté de 1,2°C. Les conséquences incluent fonte des glaciers, montée des eaux, événements météorologiques extrêmes et migrations climatiques. Les accords de Paris en 2015 visent à limiter le réchauffement à 1,5°C. La transition énergétique et les énergies renouvelables sont essentielles.
@@ -1 +0,0 @@
Préparez une béchamel avec 40g de beurre, 40g de farine et 25 cl de lait. Hors du feu, incorporez 150g de gruyère râpé et 4 jaunes d'œufs. Montez 5 blancs en neige ferme avec une pincée de sel. Incorporez délicatement les blancs à la préparation. Versez dans des ramequins beurrés et farinés. Enfournez 20 minutes à 180°C sans ouvrir le four. Servez immédiatement.
@@ -1 +0,0 @@
Dans une grande marmite, placez 1,5 kg de viande de bœuf (gîte, paleron, plat de côtes). Couvrez d'eau froide et portez à ébullition en écumant. Ajoutez 4 carottes, 4 poireaux, 2 navets, 2 oignons piqués de clous de girofle et un bouquet garni. Laissez mijoter 3 heures à feu doux. Servez le bouillon en entrée puis la viande et les légumes avec cornichons, moutarde et gros sel.
@@ -1 +0,0 @@
Faites fondre 200g de chocolat noir au bain-marie. Séparez 6 œufs et incorporez les jaunes au chocolat fondu tiède. Montez les blancs en neige ferme avec une pincée de sel et 20g de sucre. Incorporez délicatement les blancs au chocolat en trois fois avec une spatule. Répartissez dans des verrines et réfrigérez 4 heures minimum. Décorez de copeaux de chocolat ou de chantilly.
@@ -1 +0,0 @@
Épluchez et émincez finement 1,5 kg de pommes de terre. Frottez un plat à gratin avec une gousse d'ail. Disposez les pommes de terre en couches en assaisonnant de sel, poivre et noix de muscade. Mélangez 50 cl de crème avec 25 cl de lait et versez sur les pommes de terre. Ajoutez quelques noisettes de beurre. Enfournez 1h15 à 160°C jusqu'à obtenir une croûte dorée.
@@ -1 +0,0 @@
Disposez sur un plat 4 tomates en quartiers, 1 concombre émincé, 1 poivron coupé, 200g de haricots verts cuits, 4 œufs durs, 100g d'olives noires et 8 filets d'anchois. Préparez une vinaigrette avec huile d'olive, vinaigre de vin, moutarde, ail et basilic. Arrosez généreusement et servez frais. Certains ajoutent des artichauts ou des fèves.
@@ -1 +0,0 @@
Garnissez un moule de pâte sablée et faites-la cuire à blanc 15 minutes. Préparez une crème avec 4 jaunes d'œufs, 150g de sucre, le zeste et jus de 3 citrons. Cuisez à feu doux en remuant jusqu'à épaississement. Versez sur la pâte. Montez 4 blancs en neige avec 100g de sucre. Couvrez la tarte de meringue en formant des pointes. Enfournez 10 minutes à 150°C pour dorer.
@@ -1 +0,0 @@
Quadrillez la peau de 2 magrets sans entamer la chair. Salez et poivrez. Posez-les côté peau dans une poêle froide. Faites cuire 7 minutes puis retournez pour 4 minutes (rosé). Réservez au chaud. Dans la graisse, faites revenir 8 figues coupées en deux avec 2 cuillères de miel et un trait de vinaigre balsamique. Tranchez les magrets et nappez de sauce aux figues.
@@ -1 +0,0 @@
Disposez 500g de cerises lavées et équeutées (traditionnellement non dénoyautées) dans un plat beurré. Battez 4 œufs avec 100g de sucre jusqu'à ce que le mélange blanchisse. Ajoutez 100g de farine, 50 cl de lait et une pincée de sel. Versez l'appareil sur les cerises. Enfournez 45 minutes à 180°C. Saupoudrez de sucre glace et servez tiède ou froid.
@@ -1 +0,0 @@
Frottez un caquelon avec une gousse d'ail coupée. Versez 15 cl de vin blanc sec et faites chauffer. Ajoutez 400g de comté, 400g de beaufort et 200g de reblochon râpés. Remuez en forme de 8 jusqu'à ce que le fromage fonde. Ajoutez 5 cl de kirsch et du poivre. Maintenez au chaud sur un réchaud. Trempez des cubes de pain rassis piqués sur des fourchettes.
@@ -1 +0,0 @@
Faites chauffer 50 cl de crème avec une gousse de vanille fendue. Battez 6 jaunes d'œufs avec 100g de sucre. Versez la crème chaude en remuant puis filtrez. Répartissez dans des ramequins. Cuisez au bain-marie 40 minutes à 150°C. Réfrigérez 4 heures. Saupoudrez généreusement de sucre et caramélisez au chalumeau ou sous le gril. Laissez durcir 2 minutes avant de servir.
@@ -1 +0,0 @@
L'Empire romain domine le bassin méditerranéen pendant cinq siècles, de 27 avant J.-C. à 476 après J.-C. Auguste, premier empereur, établit la Pax Romana qui apporte prospérité et stabilité. Rome construit routes, aqueducs et monuments grandioses comme le Colisée. Le christianisme devient religion d'État en 380. Les invasions barbares et les crises internes provoquent la chute de l'Empire d'Occident en 476.
@@ -1 +0,0 @@
Hachez finement 600g de filet de bœuf bien frais au couteau. Ajoutez 2 échalotes ciselées, 2 cuillères de câpres, 4 cornichons hachés, persil et ciboulette. Assaisonnez avec moutarde, sauce Worcestershire, Tabasco, sel et poivre. Incorporez 2 jaunes d'œufs et un filet d'huile d'olive. Formez des dômes et servez avec des frites maison et une salade verte. Se prépare au dernier moment.
@@ -1 +0,0 @@
La musique est l'art d'organiser les sons dans le temps selon le rythme, la mélodie et l'harmonie. Présente dans toutes les cultures, elle accompagne les rites, les émotions et les récits humains depuis la préhistoire. Des modes antiques grecs au contrepoint baroque, du jazz à l'électro, elle évolue sans cesse. Les compositeurs explorent les timbres et les structures tandis que limprovisation garde la spontanéité vivante. La musique relie mathématique, émotion et mouvement.
@@ -1 +0,0 @@
Le cinéma est l'art de raconter des histoires par le mouvement des images et le son. Né à la fin du XIXe siècle avec les frères Lumière, il a rapidement fusionné technique et poésie. Le montage, la lumière et la mise en scène en font un art total mêlant littérature, théâtre et musique. Du muet de Chaplin au cinéma numérique, chaque époque invente un nouveau langage visuel. Le cinéma explore la mémoire, les rêves et la condition humaine à travers l’écran.
@@ -1 +0,0 @@
La danse est lart du mouvement du corps dans lespace et le temps, souvent accompagné de musique. Elle exprime des émotions, raconte des histoires ou célèbre des rites. Des danses tribales aux ballets classiques, des danses contemporaines au hip-hop, chaque culture invente ses gestes et son rythme. La chorégraphie unit discipline et liberté, le corps devenant un instrument expressif. La danse relie énergie, esthétique et communication non verbale.
@@ -1 +0,0 @@
La peinture à l'huile est une technique artistique utilisant des pigments mélangés à de l'huile siccative, généralement de lin. Inventée au XVe siècle et perfectionnée par les maîtres flamands comme Van Eyck, elle permet des glacis subtils et des dégradés lumineux. Le temps de séchage lent offre la possibilité de travailler les transitions et les détails. Les grands maîtres comme Rembrandt, Vermeer et plus tard les impressionnistes ont exploité ses possibilités. Cette technique reste aujourd'hui la plus prisée pour la peinture de chevalet.
@@ -1 +0,0 @@
La sculpture sur pierre est l'un des arts les plus anciens de l'humanité, remontant à la préhistoire. Le sculpteur taille directement dans le marbre, le granit ou le calcaire avec des ciseaux et des masses. Michel-Ange considérait que la statue existait déjà dans le bloc, il suffisait de libérer la forme. Cette technique soustractive ne pardonne pas l'erreur. Les œuvres comme le David ou la Pietà démontrent la capacité de donner vie et émotion à la pierre froide.
@@ -1 +0,0 @@
La calligraphie est l'art de former les lettres avec beauté et harmonie. En Occident, les moines copistes médiévaux ont perfectionné l'onciale et la gothique. En Asie, la calligraphie chinoise et japonaise est considérée comme la forme d'art la plus pure, où chaque trait exprime l'énergie et l'esprit de l'artiste. L'outil traditionnel est le pinceau ou le calame. La maîtrise nécessite des années de pratique pour contrôler la pression, la vitesse et le rythme.

Some files were not shown because too many files have changed in this diff Show More