Skip to content

Commit 07c958d

Browse files
claudefrousselet
authored andcommitted
Give the semantic index its own Administration menu entry
The index rebuild control was buried at the bottom of the Company settings page, where it was hard to find. Move it to a dedicated "Semantic index" page with its own sidebar entry under Administration, next to "Assistant feedback". - New SemanticIndexAdminView (GET, system.config.read) renders the status card (indexed/total, last updated, embedding model) and the rebuild button. - Sidebar link shown when AI_ASSISTANT_ENABLED and the user has system.config.read; gated icon bi-stars. - Rebuild POST redirects back to the new page; card removed from Company settings (and its context reverted). - French translations, a page render + permission test, and docs/CHANGELOG updated to point at Administration -> Semantic index. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0b3b393 commit 07c958d

11 files changed

Lines changed: 120 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **Ask Cairn: OpenAI and OpenAI-compatible providers**: the assistant gains an `openai` backend (`AI_ASSISTANT_PROVIDER=openai`) that targets OpenAI (ChatGPT, e.g. `gpt-4o-mini`) out of the box and, via `AI_ASSISTANT_BASE_URL`, any other endpoint implementing the OpenAI `/chat/completions` and `/embeddings` API (vLLM, LiteLLM, LocalAI, Together, Groq...). The shared request/response handling was extracted into a generic `OpenAICompatibleClient`; the existing `MistralClient` is now a thin subclass of it (Mistral already exposes an OpenAI-compatible API), so behaviour is unchanged for Mistral users. `AI_ASSISTANT_BASE_URL` now defaults to empty and each provider falls back to its own endpoint (`mistral` -> `api.mistral.ai`, `openai` -> `api.openai.com`, `anthropic` -> `api.anthropic.com`); set it only to target a custom gateway.
1313
- **Ask Cairn: Claude (Anthropic) provider**: a native `anthropic` backend (`AI_ASSISTANT_PROVIDER=anthropic`) talks to Claude through the Messages API (`POST /v1/messages`, `x-api-key` header, top-level `system`, `content` block list) - Claude is not OpenAI-compatible, so it has its own client. Routing uses forced tool use (a `plan` tool whose `input_schema` is the routing schema) and no `temperature`/`thinking` is sent (both 400 on the current Opus family). Set `AI_ASSISTANT_MODEL` to a Claude model id (e.g. `claude-opus-4-8`). Semantic search is not available with this provider, since Anthropic has no embeddings API.
14-
- **Ask Cairn: automatic semantic index maintenance**: the requirement semantic index now stays fresh without a manual command. A `post_delete` signal prunes a deleted requirement's embedding immediately (no provider call); the index is refreshed in a guarded background thread when a server process starts (when `AI_ASSISTANT_SEMANTIC_ENABLED`); and the Company settings page gains an index-status panel (indexed / total requirements, last updated, embedding model) with an **"Update the index now"** button (gated by `system.config.update`) that triggers a background rebuild. Embedding stays off the request path - requirement saves never embed inline; the documented daily `rebuild_semantic_index` cron remains the self-healing backstop. The rebuild logic was extracted into `assistant.semantic.rebuild_index` / `rebuild_index_async` (cache-locked to dedupe overlapping triggers) and reused by the management command.
14+
- **Ask Cairn: automatic semantic index maintenance**: the requirement semantic index now stays fresh without a manual command. A `post_delete` signal prunes a deleted requirement's embedding immediately (no provider call); the index is refreshed in a guarded background thread when a server process starts (when `AI_ASSISTANT_SEMANTIC_ENABLED`); and a dedicated **Administration -> Semantic index** page shows an index-status panel (indexed / total requirements, last updated, embedding model) with an **"Update the index now"** button (gated by `system.config.update`) that triggers a background rebuild. Embedding stays off the request path - requirement saves never embed inline; the documented daily `rebuild_semantic_index` cron remains the self-healing backstop. The rebuild logic was extracted into `assistant.semantic.rebuild_index` / `rebuild_index_async` (cache-locked to dedupe overlapping triggers) and reused by the management command.
1515

1616
## [0.27.1] - 2026-06-14
1717

accounts/templates/accounts/company_settings.html

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -85,49 +85,6 @@ <h2>{% trans "Company settings" %}</h2>
8585
</div>
8686
</div>
8787
</form>
88-
89-
{# ── Ask Cairn: semantic search index ──────────────── #}
90-
<div class="row">
91-
<div class="col-lg-8">
92-
<div class="card mb-4">
93-
<div class="card-header"><h6 class="mb-0"><i class="bi bi-stars me-2" style="color:var(--accent)"></i>{% trans "Semantic search index" %}</h6></div>
94-
<div class="card-body">
95-
{% if not semantic.enabled %}
96-
<p class="text-muted mb-0">{% trans "Semantic search is disabled." %} {% trans "Enable it with the AI_ASSISTANT_SEMANTIC_ENABLED setting to let topic questions match requirements across languages." %}</p>
97-
{% elif not semantic.embeddings_supported %}
98-
<p class="text-muted mb-0"><i class="bi bi-exclamation-triangle me-1"></i>{% trans "The current AI provider has no embeddings, so the semantic index cannot be built. Use the Mistral, OpenAI or Ollama provider to index requirements." %}</p>
99-
{% else %}
100-
<div class="row g-3 mb-3">
101-
<div class="col-sm-4">
102-
<div class="form-label mb-0">{% trans "Indexed requirements" %}</div>
103-
<div style="font-weight:600;font-size:1.0625rem">{{ semantic.indexed }} / {{ semantic.total }}</div>
104-
</div>
105-
<div class="col-sm-4">
106-
<div class="form-label mb-0">{% trans "Last updated" %}</div>
107-
<div style="font-weight:600;font-size:1.0625rem">{% if semantic.last_updated %}{{ semantic.last_updated|date:"SHORT_DATETIME_FORMAT" }}{% else %}{% trans "Never" %}{% endif %}</div>
108-
</div>
109-
<div class="col-sm-4">
110-
<div class="form-label mb-0">{% trans "Embedding model" %}</div>
111-
<div style="font-weight:600;font-size:1.0625rem">{{ semantic.embed_model }}</div>
112-
</div>
113-
</div>
114-
{% if semantic.running %}
115-
<p class="text-muted mb-3"><span class="spinner-border spinner-border-sm me-2"></span>{% trans "An update is currently running." %}</p>
116-
{% endif %}
117-
<p class="text-muted small mb-3">{% trans "New and edited requirements are picked up automatically (daily and at startup); use this button to refresh immediately, e.g. after a bulk import." %}</p>
118-
{% if can_edit %}
119-
<form method="post" action="{% url 'assistant:rebuild-semantic-index' %}">
120-
{% csrf_token %}
121-
<button type="submit" class="btn btn-outline-primary"{% if semantic.running %} disabled{% endif %}>
122-
<i class="bi bi-arrow-repeat me-1"></i>{% trans "Update the index now" %}
123-
</button>
124-
</form>
125-
{% endif %}
126-
{% endif %}
127-
</div>
128-
</div>
129-
</div>
130-
</div>
13188
{% endblock %}
13289

13390
{% block extra_js %}

accounts/views.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -162,21 +162,11 @@ def post(self, request):
162162
class CompanySettingsView(LoginRequiredMixin, PermissionRequiredMixin, View):
163163
permission_required = "system.config.read"
164164

165-
def _context(self, form, instance, can_edit):
166-
from assistant.semantic import index_status
167-
168-
return {
169-
"form": form,
170-
"company": instance,
171-
"can_edit": can_edit,
172-
"semantic": index_status(),
173-
}
174-
175165
def get(self, request):
176166
instance = CompanySettings.get()
177167
form = CompanySettingsForm(instance=instance)
178168
can_edit = request.user.is_superuser or request.user.has_perm("system.config.update")
179-
return render(request, "accounts/company_settings.html", self._context(form, instance, can_edit))
169+
return render(request, "accounts/company_settings.html", {"form": form, "company": instance, "can_edit": can_edit})
180170

181171
def post(self, request):
182172
can_edit = request.user.is_superuser or request.user.has_perm("system.config.update")
@@ -189,7 +179,7 @@ def post(self, request):
189179
form.save()
190180
messages.success(request, _("Company settings updated."))
191181
return redirect("accounts:company-settings")
192-
return render(request, "accounts/company_settings.html", self._context(form, instance, can_edit))
182+
return render(request, "accounts/company_settings.html", {"form": form, "company": instance, "can_edit": can_edit})
193183

194184

195185
# ── Users ───────────────────────────────────────────────────
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{% extends "base.html" %}
2+
{% load ui %}
3+
{% load i18n %}
4+
{% block title %}{% trans "Semantic index - Cairn" %}{% endblock %}
5+
6+
{% block content %}
7+
{% page_header _("Semantic index") eyebrow=_("Administration") accent="accounts" %}{% endpage_header %}
8+
9+
<div class="row">
10+
<div class="col-lg-8">
11+
<div class="card mb-4">
12+
<div class="card-header"><h6 class="mb-0"><i class="bi bi-stars me-2" style="color:var(--accent)"></i>{% trans "Semantic search index" %}</h6></div>
13+
<div class="card-body">
14+
{% if not semantic.enabled %}
15+
<p class="text-muted mb-0">{% trans "Semantic search is disabled." %} {% trans "Enable it with the AI_ASSISTANT_SEMANTIC_ENABLED setting to let topic questions match requirements across languages." %}</p>
16+
{% elif not semantic.embeddings_supported %}
17+
<p class="text-muted mb-0"><i class="bi bi-exclamation-triangle me-1"></i>{% trans "The current AI provider has no embeddings, so the semantic index cannot be built. Use the Mistral, OpenAI or Ollama provider to index requirements." %}</p>
18+
{% else %}
19+
<div class="row g-3 mb-3">
20+
<div class="col-sm-4">
21+
<div class="form-label mb-0">{% trans "Indexed requirements" %}</div>
22+
<div style="font-weight:600;font-size:1.0625rem">{{ semantic.indexed }} / {{ semantic.total }}</div>
23+
</div>
24+
<div class="col-sm-4">
25+
<div class="form-label mb-0">{% trans "Last updated" %}</div>
26+
<div style="font-weight:600;font-size:1.0625rem">{% if semantic.last_updated %}{{ semantic.last_updated|date:"SHORT_DATETIME_FORMAT" }}{% else %}{% trans "Never" %}{% endif %}</div>
27+
</div>
28+
<div class="col-sm-4">
29+
<div class="form-label mb-0">{% trans "Embedding model" %}</div>
30+
<div style="font-weight:600;font-size:1.0625rem">{{ semantic.embed_model }}</div>
31+
</div>
32+
</div>
33+
{% if semantic.running %}
34+
<p class="text-muted mb-3"><span class="spinner-border spinner-border-sm me-2"></span>{% trans "An update is currently running." %}</p>
35+
{% endif %}
36+
<p class="text-muted small mb-3">{% trans "New and edited requirements are picked up automatically (daily and at startup); use this button to refresh immediately, e.g. after a bulk import." %}</p>
37+
{% if can_edit %}
38+
<form method="post" action="{% url 'assistant:rebuild-semantic-index' %}">
39+
{% csrf_token %}
40+
<button type="submit" class="btn btn-outline-primary"{% if semantic.running %} disabled{% endif %}>
41+
<i class="bi bi-arrow-repeat me-1"></i>{% trans "Update the index now" %}
42+
</button>
43+
</form>
44+
{% endif %}
45+
{% endif %}
46+
</div>
47+
</div>
48+
</div>
49+
</div>
50+
{% endblock %}

assistant/tests/test_semantic_maintenance.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,26 @@ def test_rebuild_view_reports_disabled(client, monkeypatch):
141141
assert "called" not in started
142142

143143

144+
@pytest.mark.django_db
145+
@override_settings(AI_ASSISTANT_SEMANTIC_ENABLED=True)
146+
def test_semantic_index_admin_page_requires_permission(client):
147+
user = UserFactory() # no system.config.read
148+
client.force_login(user)
149+
resp = client.get(reverse("assistant:semantic-index"))
150+
assert resp.status_code == 403
151+
152+
153+
@pytest.mark.django_db
154+
@override_settings(AI_ASSISTANT_SEMANTIC_ENABLED=True, AI_ASSISTANT_PROVIDER="mistral")
155+
def test_semantic_index_admin_page_renders_status(client):
156+
user = UserFactory()
157+
_grant(user, "system.config.read")
158+
client.force_login(user)
159+
resp = client.get(reverse("assistant:semantic-index"))
160+
assert resp.status_code == 200
161+
assert resp.context["semantic"]["enabled"] is True
162+
163+
144164
@pytest.mark.django_db
145165
@override_settings(AI_ASSISTANT_SEMANTIC_ENABLED=True)
146166
def test_rebuild_view_starts_rebuild_with_permission(client, monkeypatch):

assistant/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
path("feedback/list/", views.AssistantFeedbackListView.as_view(), name="feedback-list"),
1111
path("feedback/export/", views.AssistantFeedbackExportView.as_view(), name="feedback-export"),
1212
path("feedback/<uuid:pk>/resolve/", views.AssistantFeedbackResolveView.as_view(), name="feedback-resolve"),
13+
path("semantic-index/", views.SemanticIndexAdminView.as_view(), name="semantic-index"),
1314
path("semantic-index/rebuild/", views.RebuildSemanticIndexView.as_view(), name="rebuild-semantic-index"),
1415
]

assistant/views.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,35 @@ def _safe_query(self, request):
262262
return f"?{encoded}" if encoded else ""
263263

264264

265+
class SemanticIndexAdminView(LoginRequiredMixin, PermissionRequiredMixin, View):
266+
"""In-app Administration page for the requirement semantic index.
267+
268+
Shows index status (indexed / total requirements, last updated, embedding
269+
model) and the on-demand rebuild button.
270+
"""
271+
272+
permission_required = "system.config.read"
273+
http_method_names = ["get"]
274+
275+
def get(self, request):
276+
from assistant.semantic import index_status
277+
278+
can_edit = request.user.is_superuser or request.user.has_perm(
279+
"system.config.update"
280+
)
281+
return render(
282+
request,
283+
"assistant/semantic_index.html",
284+
{"semantic": index_status(), "can_edit": can_edit},
285+
)
286+
287+
265288
class RebuildSemanticIndexView(LoginRequiredMixin, View):
266289
"""Trigger an on-demand background refresh of the requirement index.
267290
268-
Lives on the Company settings page (in-app Administration), gated by the
269-
same permission as other system configuration changes. The rebuild runs in
270-
a guarded background thread so the request returns immediately.
291+
Posted from the Semantic index admin page, gated by the same permission as
292+
other system configuration changes. The rebuild runs in a guarded background
293+
thread so the request returns immediately.
271294
"""
272295

273296
http_method_names = ["post"]
@@ -278,10 +301,10 @@ def post(self, request):
278301
or request.user.has_perm("system.config.update")
279302
):
280303
messages.error(request, _("You do not have the required permissions."))
281-
return redirect("accounts:company-settings")
304+
return redirect("assistant:semantic-index")
282305
if not settings.AI_ASSISTANT_SEMANTIC_ENABLED:
283306
messages.error(request, _("Semantic search is disabled."))
284-
return redirect("accounts:company-settings")
307+
return redirect("assistant:semantic-index")
285308

286309
from assistant.semantic import rebuild_index_async
287310

@@ -292,4 +315,4 @@ def post(self, request):
292315
)
293316
else:
294317
messages.info(request, _("A semantic index update is already running."))
295-
return redirect("accounts:company-settings")
318+
return redirect("assistant:semantic-index")

docs/installation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Both accept `--dry-run` to preview changes. A typical host cron entry:
140140
20 2 * * * cd /opt/cairn && docker compose exec -T web python manage.py mark_overdue_treatment_plans
141141
```
142142

143-
If the **semantic search** of the Ask Cairn assistant is enabled (`AI_ASSISTANT_SEMANTIC_ENABLED`), schedule the index refresh the same way. The command is idempotent (it only re-embeds changed requirements and prunes deleted ones), so a daily run is cheap. The index is also refreshed automatically when the app starts, a deleted requirement is pruned immediately, and an administrator can force a refresh from the Company settings page; the daily cron is the reliable, self-healing backstop.
143+
If the **semantic search** of the Ask Cairn assistant is enabled (`AI_ASSISTANT_SEMANTIC_ENABLED`), schedule the index refresh the same way. The command is idempotent (it only re-embeds changed requirements and prunes deleted ones), so a daily run is cheap. The index is also refreshed automatically when the app starts, a deleted requirement is pruned immediately, and an administrator can force a refresh from the **Administration -> Semantic index** page; the daily cron is the reliable, self-healing backstop.
144144

145145
```cron
146146
25 2 * * * cd /opt/cairn && docker compose exec -T web python manage.py rebuild_semantic_index

docs/modules/assistant/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ The index drifts as requirements are created, edited or deleted, so it is refres
206206

207207
- **On delete (immediate)**: a `post_delete` signal on `Requirement` (`assistant/signals.py`, wired from `AssistantConfig.ready()`) prunes the requirement's `SemanticIndex` row. This is a network-free DB delete, safe even when semantic search is disabled.
208208
- **On startup**: when a server process boots (uvicorn / `runserver`) with the feature on, `AssistantConfig.ready()` launches a guarded background thread that runs the rebuild once (skipped for management commands like `migrate`/`test`, so the work never blocks boot and a slow provider can't wedge startup).
209-
- **On demand (admin)**: the Company settings page (in-app Administration, gated by `system.config.update`) shows index status (indexed / total requirements, last updated, embedding model) and an **"Update the index now"** button that triggers a background rebuild (`assistant:rebuild-semantic-index`). The card warns when the active provider has no embeddings (e.g. `anthropic`).
209+
- **On demand (admin)**: a dedicated **Administration -> Semantic index** page (`assistant:semantic-index`, sidebar link shown when `AI_ASSISTANT_ENABLED` and the user has `system.config.read`) shows index status (indexed / total requirements, last updated, embedding model) and an **"Update the index now"** button (gated by `system.config.update`) that triggers a background rebuild. It warns when the active provider has no embeddings (e.g. `anthropic`).
210210
- **Scheduled (recommended backstop)**: run `manage.py rebuild_semantic_index` from cron (see [installation.md](../../installation.md)). It is the reliable, self-healing mechanism - it catches anything the others missed (e.g. an edit that no rebuild has run for yet, or an embed that failed transiently).
211211

212212
A cache lock (`assistant.semantic.rebuild_index_async`) dedupes overlapping triggers (startup + button + a double click), and the startup/admin rebuilds run in daemon threads that close their own DB connections. New and edited requirements become searchable at the next rebuild (startup, daily cron, or the admin button) - lexical search covers them immediately in the meantime.

locale/fr/LC_MESSAGES/django.po

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9905,3 +9905,9 @@ msgstr "La mise à jour de l'index sémantique a démarré ; elle s'exécute en
99059905

99069906
msgid "A semantic index update is already running."
99079907
msgstr "Une mise à jour de l'index sémantique est déjà en cours."
9908+
9909+
msgid "Semantic index"
9910+
msgstr "Index sémantique"
9911+
9912+
msgid "Semantic index - Cairn"
9913+
msgstr "Index sémantique - Cairn"

0 commit comments

Comments
 (0)