teuthis-cms 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- app/__init__.py +0 -0
- app/ai/__init__.py +58 -0
- app/ai/administration.py +326 -0
- app/ai/breaker.py +84 -0
- app/ai/capabilities.py +101 -0
- app/ai/catalogue/models.yaml +178 -0
- app/ai/catalogue/providers.yaml +93 -0
- app/ai/embeddings.py +470 -0
- app/ai/journal.py +165 -0
- app/ai/manager.py +727 -0
- app/ai/policies.py +320 -0
- app/ai/ports.py +302 -0
- app/ai/providers/__init__.py +17 -0
- app/ai/providers/loopback.py +138 -0
- app/ai/registry.py +379 -0
- app/ai/runtime.py +49 -0
- app/ai/schemas.py +358 -0
- app/ai/sovereignty.py +144 -0
- app/ai/suggestions.py +265 -0
- app/ai/tasks.py +232 -0
- app/ai/vector.py +82 -0
- app/ai_editorial/__init__.py +74 -0
- app/ai_editorial/alt_text.py +220 -0
- app/ai_editorial/chunking.py +136 -0
- app/ai_editorial/extraction.py +232 -0
- app/ai_editorial/obsolescence.py +596 -0
- app/ai_editorial/rag.py +709 -0
- app/ai_editorial/references.py +155 -0
- app/ai_editorial/schemas.py +305 -0
- app/ai_editorial/seo.py +450 -0
- app/alembic/env.py +59 -0
- app/alembic/script.py.mako +24 -0
- app/alembic/versions/0001_initial_tenant_site.py +58 -0
- app/alembic/versions/0002_content_type_engine.py +76 -0
- app/alembic/versions/0003_page_builder.py +67 -0
- app/alembic/versions/0004_listing_engine.py +117 -0
- app/alembic/versions/0005_lifecycle_workflow.py +139 -0
- app/alembic/versions/0006_rgpd_audit_cookies.py +251 -0
- app/alembic/versions/0007_rbac_acl_extranet.py +291 -0
- app/alembic/versions/0008_plugin_sdk.py +111 -0
- app/alembic/versions/0009_ai_socle.py +469 -0
- app/alembic/versions/0010_forms_notifications_discussions.py +229 -0
- app/alembic/versions/0011_recherche_complete.py +229 -0
- app/alembic/versions/0012_comptes_usagers.py +431 -0
- app/alembic/versions/0013_conformite_generee.py +190 -0
- app/alembic/versions/0014_multisite_propagation.py +307 -0
- app/alembic/versions/0015_json_none_backfill.py +58 -0
- app/api/__init__.py +0 -0
- app/api/access.py +511 -0
- app/api/account.py +775 -0
- app/api/ai_admin.py +597 -0
- app/api/ai_editorial.py +789 -0
- app/api/auth.py +577 -0
- app/api/compliance.py +791 -0
- app/api/content_types.py +559 -0
- app/api/cookies.py +435 -0
- app/api/discussions.py +206 -0
- app/api/end_user_admin.py +145 -0
- app/api/exports.py +158 -0
- app/api/forms.py +248 -0
- app/api/lifecycle.py +492 -0
- app/api/listings.py +194 -0
- app/api/multisite.py +658 -0
- app/api/pages.py +600 -0
- app/api/personal_files.py +134 -0
- app/api/plugins.py +244 -0
- app/api/public_forms.py +515 -0
- app/api/rgpd.py +479 -0
- app/api/search.py +135 -0
- app/api/search_dictionaries.py +379 -0
- app/api/staff_users.py +424 -0
- app/bundled_plugins/__init__.py +55 -0
- app/bundled_plugins/cartography.py +264 -0
- app/bundled_plugins/comments.py +37 -0
- app/bundled_plugins/sve.py +445 -0
- app/cli/__init__.py +34 -0
- app/cli/docker.py +241 -0
- app/cli/main.py +299 -0
- app/cli/manage.py +129 -0
- app/cli/migrate.py +68 -0
- app/cli/templates.py +143 -0
- app/compliance/__init__.py +30 -0
- app/compliance/accessibility.py +479 -0
- app/compliance/attributions.py +152 -0
- app/compliance/disclaimer.py +111 -0
- app/compliance/policy.py +114 -0
- app/compliance/privacy_policy.py +253 -0
- app/compliance/referential.py +330 -0
- app/compliance/referentials/rgaa-4.1.2.yaml +657 -0
- app/compliance/runtime.py +43 -0
- app/compliance/schemas.py +218 -0
- app/content/__init__.py +0 -0
- app/content/conditions.py +257 -0
- app/content/fields.py +555 -0
- app/content/geo.py +247 -0
- app/content/query.py +226 -0
- app/content/schemas.py +155 -0
- app/core/__init__.py +0 -0
- app/core/config.py +130 -0
- app/core/database.py +20 -0
- app/discussions/__init__.py +6 -0
- app/discussions/service.py +137 -0
- app/discussions/threads.py +114 -0
- app/end_users/__init__.py +14 -0
- app/end_users/accounts.py +466 -0
- app/end_users/export.py +358 -0
- app/end_users/identity.py +144 -0
- app/end_users/lifecycle.py +586 -0
- app/end_users/oidc.py +550 -0
- app/end_users/policy.py +239 -0
- app/end_users/prefill.py +94 -0
- app/end_users/schemas.py +281 -0
- app/end_users/sessions.py +167 -0
- app/end_users/space.py +382 -0
- app/forms/__init__.py +22 -0
- app/forms/captcha.py +246 -0
- app/forms/config.py +255 -0
- app/forms/export.py +320 -0
- app/forms/prefill.py +127 -0
- app/forms/submissions.py +553 -0
- app/forms/validation.py +223 -0
- app/lifecycle/__init__.py +1 -0
- app/lifecycle/engine.py +345 -0
- app/lifecycle/notifications.py +114 -0
- app/lifecycle/registry.py +332 -0
- app/lifecycle/rules.py +471 -0
- app/lifecycle/schemas.py +104 -0
- app/lifecycle/settings.py +355 -0
- app/lifecycle/workflow.py +202 -0
- app/listing/__init__.py +0 -0
- app/listing/config.py +366 -0
- app/listing/filters.py +378 -0
- app/listing/indexing.py +419 -0
- app/listing/resolver.py +320 -0
- app/listing/rss.py +72 -0
- app/listing/schemas.py +32 -0
- app/main.py +187 -0
- app/models/__init__.py +86 -0
- app/models/access.py +84 -0
- app/models/accessibility.py +142 -0
- app/models/ai.py +524 -0
- app/models/content.py +193 -0
- app/models/discussion.py +86 -0
- app/models/end_user.py +357 -0
- app/models/end_user_space.py +138 -0
- app/models/forms.py +146 -0
- app/models/lifecycle.py +60 -0
- app/models/multisite.py +239 -0
- app/models/notification.py +134 -0
- app/models/page_builder.py +65 -0
- app/models/personal_file.py +119 -0
- app/models/plugin.py +99 -0
- app/models/rgpd.py +225 -0
- app/models/search.py +157 -0
- app/models/search_index.py +189 -0
- app/models/staff.py +178 -0
- app/models/tenant.py +120 -0
- app/multisite/__init__.py +14 -0
- app/multisite/factory.py +513 -0
- app/multisite/guards.py +129 -0
- app/multisite/hashing.py +126 -0
- app/multisite/projection.py +190 -0
- app/multisite/replication.py +626 -0
- app/multisite/schema_lock.py +142 -0
- app/multisite/schemas.py +145 -0
- app/multisite/sharing.py +305 -0
- app/multisite/taxonomy.py +234 -0
- app/notifications/__init__.py +14 -0
- app/notifications/bridge.py +111 -0
- app/notifications/engine.py +464 -0
- app/notifications/templates.py +230 -0
- app/page_builder/__init__.py +0 -0
- app/page_builder/blocks.py +367 -0
- app/page_builder/columns.py +36 -0
- app/page_builder/content_source.py +239 -0
- app/page_builder/layout.py +48 -0
- app/page_builder/render.py +115 -0
- app/page_builder/schemas.py +143 -0
- app/page_builder/validation.py +318 -0
- app/plugins/__init__.py +275 -0
- app/plugins/ai.py +225 -0
- app/plugins/catalogue.py +68 -0
- app/plugins/composition.py +351 -0
- app/plugins/installation.py +296 -0
- app/plugins/manifest.py +603 -0
- app/plugins/runtime.py +53 -0
- app/plugins/schemas.py +96 -0
- app/rbac/__init__.py +53 -0
- app/rbac/acl.py +551 -0
- app/rbac/identity.py +277 -0
- app/rbac/mfa.py +132 -0
- app/rbac/passwords.py +226 -0
- app/rbac/policy.py +98 -0
- app/rbac/providers.py +181 -0
- app/rbac/roles.py +388 -0
- app/rbac/schemas.py +370 -0
- app/rbac/sessions.py +188 -0
- app/rgpd/__init__.py +1 -0
- app/rgpd/actor.py +104 -0
- app/rgpd/cookies.py +495 -0
- app/rgpd/detection.py +286 -0
- app/rgpd/exclusion.py +101 -0
- app/rgpd/journal.py +383 -0
- app/rgpd/policy.py +109 -0
- app/rgpd/processing.py +196 -0
- app/rgpd/purge.py +184 -0
- app/rgpd/schemas.py +276 -0
- app/search/__init__.py +31 -0
- app/search/dictionaries.py +260 -0
- app/search/engine.py +869 -0
- app/search/normalize.py +100 -0
- app/search/query.py +351 -0
- app/search/reindex.py +129 -0
- app/search/schemas.py +82 -0
- app/storage/__init__.py +7 -0
- app/storage/personal_files.py +418 -0
- teuthis_cms-0.1.0.dist-info/METADATA +19 -0
- teuthis_cms-0.1.0.dist-info/RECORD +220 -0
- teuthis_cms-0.1.0.dist-info/WHEEL +4 -0
- teuthis_cms-0.1.0.dist-info/entry_points.txt +2 -0
app/__init__.py
ADDED
|
File without changes
|
app/ai/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Socle IA — Epic E8 (CDC §14, jalon M8a).
|
|
2
|
+
|
|
3
|
+
**Ce que cet Epic livre : de quoi appeler une IA correctement. Pas une seule
|
|
4
|
+
fonction éditoriale.** Le texte alternatif, l'extraction documentaire, le
|
|
5
|
+
RAG, la détection d'obsolescence et la classification SVE sont l'Epic E9
|
|
6
|
+
(M8b) ; ils s'appuient sur ce qui est ici, et n'y sont pas — ils vivent dans
|
|
7
|
+
**`app/ai_editorial`**, un paquet distinct qui importe celui-ci et jamais
|
|
8
|
+
l'inverse. Un test de recette d'E9 vérifie que le socle ne nomme aucune
|
|
9
|
+
tâche éditoriale : c'est ce qui rend la séparation entre « savoir appeler un
|
|
10
|
+
modèle correctement » et « savoir quoi lui demander » vérifiable plutôt que
|
|
11
|
+
déclarative.
|
|
12
|
+
|
|
13
|
+
| Module | Ce qu'il porte |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `capabilities` | Les cinq capacités typées, et la traduction depuis le vocabulaire d'E7 |
|
|
16
|
+
| `registry` | Le Model Registry — entrées déclaratives **YAML**, validées par Pydantic |
|
|
17
|
+
| `tasks` | Les tâches du Core et leur régime de sortie (`required` / `optional` / `none`) |
|
|
18
|
+
| `sovereignty` | Le mode d'installation (`restricted` par défaut) et ce qu'il autorise |
|
|
19
|
+
| `policies` | La résolution `(tâche, tenant, contexte) → modèle` |
|
|
20
|
+
| `administration` | Enregistrer fournisseur, modèle (avec sa déclaration), politique |
|
|
21
|
+
| `ports` | Cinq ports typés — jamais une interface `AIProvider` fourre-tout |
|
|
22
|
+
| `providers` | Les adaptateurs. Un seul livré ici : un fournisseur hors-ligne, pour la recette |
|
|
23
|
+
| `breaker` | Le disjoncteur par `(tenant, modèle)` |
|
|
24
|
+
| `journal` | `ai_jobs` : modèle, jetons, latence, coût, **sortie de données oui/non** |
|
|
25
|
+
| `suggestions` | `ai_suggestions` : la seule porte de sortie d'une production d'IA |
|
|
26
|
+
| `embeddings` | Isolation multi-tenant des vecteurs, garde-fou de réindexation |
|
|
27
|
+
| `manager` | Le Provider Manager : le point de passage unique |
|
|
28
|
+
| `runtime` | Le gestionnaire du processus — absent par défaut |
|
|
29
|
+
|
|
30
|
+
**Les quatre règles qui n'admettent aucune exception**, répétées ici parce
|
|
31
|
+
qu'elles se vérifient dans plusieurs modules à la fois :
|
|
32
|
+
|
|
33
|
+
1. *Aucune fonctionnalité du Core ne dépend d'un appel IA pour aboutir*
|
|
34
|
+
(CDC §14.4). `invoke()` ne lève pas sur panne, la base n'accepte que
|
|
35
|
+
`on_failure='degrade'`, et un test structurel vérifie qu'aucun module
|
|
36
|
+
d'API du Core n'importe ce paquet.
|
|
37
|
+
2. *Toute production d'IA passe par `ai_suggestions`* — sauf les sorties
|
|
38
|
+
structurelles (embeddings, extraction), qui n'ont rien à faire valider.
|
|
39
|
+
3. *`data_leaves_infrastructure` est déclaré, jamais déduit.*
|
|
40
|
+
4. *Changer de modèle d'embedding est une migration*, refusée
|
|
41
|
+
techniquement tant qu'une réindexation n'est pas planifiée (ADR-0004,
|
|
42
|
+
dont la décision de fond — quel modèle par défaut — reste ouverte : ce
|
|
43
|
+
socle n'en élit aucun).
|
|
44
|
+
|
|
45
|
+
**Le CMS n'affirme aucune conformité réglementaire.** Ce qui est vérifiable
|
|
46
|
+
ici, et vérifié, c'est où partent les données, qui l'a déclaré, et ce que
|
|
47
|
+
chaque appel a consommé.
|
|
48
|
+
|
|
49
|
+
**Ce paquet ne réexporte rien, contrairement à `app/plugins`.** Ce n'est pas
|
|
50
|
+
un oubli : `app/models/ai.py` a besoin du type `vector`
|
|
51
|
+
(`app.ai.vector`), et `app.ai.capabilities` a besoin du vocabulaire des
|
|
52
|
+
plugins (`app.plugins.manifest`), lequel finit par importer `app.models`.
|
|
53
|
+
Un `__init__` qui importerait le gestionnaire refermerait cette boucle et
|
|
54
|
+
rendrait l'ordre des imports significatif — le genre de dépendance qui
|
|
55
|
+
casse un jour, dans un module qui n'a rien demandé. Les appelants importent
|
|
56
|
+
donc le sous-module dont ils ont besoin : `from app.ai.manager import
|
|
57
|
+
ProviderManager`. La table ci-dessus dit lequel.
|
|
58
|
+
"""
|
app/ai/administration.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Enregistrer un fournisseur, un modèle, une politique — Epic E8 (CDC §14.3).
|
|
2
|
+
|
|
3
|
+
**Ces trois écritures sont des actes d'administration, pas des réglages.**
|
|
4
|
+
Enregistrer un modèle, c'est déclarer où partent des données ; poser une
|
|
5
|
+
politique, c'est décider qui, sur quel site, peut demander quoi à ce modèle.
|
|
6
|
+
Ce module les traite comme telles : chaque écriture exige un `*_login`, la
|
|
7
|
+
déclaration de sortie de données n'a **aucune valeur par défaut** dans la
|
|
8
|
+
signature — il faut la dire — et les refus sont collectés puis levés d'un
|
|
9
|
+
bloc, comme partout dans ce dépôt depuis E2.
|
|
10
|
+
|
|
11
|
+
**Pourquoi des fonctions de service et pas des routes HTTP ?** L'écran
|
|
12
|
+
d'administration IA appartient au front d'administration, qui n'est pas au
|
|
13
|
+
périmètre de ce jalon ; y adosser des routes maintenant aurait exigé une
|
|
14
|
+
permission `ai.manage` dans le catalogue RBAC (E6) au moment où personne ne
|
|
15
|
+
peut encore l'exercer depuis une interface. Ces fonctions sont le
|
|
16
|
+
comportement que ces routes appelleront ; elles sont testées comme telles.
|
|
17
|
+
|
|
18
|
+
**Ce que ce module refuse, et pourquoi ce n'est pas de la rigidité.**
|
|
19
|
+
|
|
20
|
+
- Un modèle dont l'entrée de catalogue est inconnue : sans elle, on ne sait
|
|
21
|
+
ni quel nom de runtime envoyer ni quel protocole parler. Inventer aurait
|
|
22
|
+
produit un appel qui échoue chez le fournisseur, après la sortie des
|
|
23
|
+
données.
|
|
24
|
+
- Un modèle servi par un point d'accès déclaré tiers, mais déclaré comme ne
|
|
25
|
+
faisant pas sortir les données. L'inverse reste permis : un point d'accès
|
|
26
|
+
interne *peut* relayer vers un tiers, et l'administrateur doit pouvoir le
|
|
27
|
+
déclarer sortant.
|
|
28
|
+
- Une déclaration qui contredit l'indication du catalogue **sans
|
|
29
|
+
justification écrite**. Le CMS n'a pas les moyens de trancher, mais il a
|
|
30
|
+
ceux d'exiger que la contradiction soit assumée par écrit et attribuée.
|
|
31
|
+
- Un changement de modèle d'embedding sans réindexation planifiée
|
|
32
|
+
(ADR-0004). Refus opposé ici *et* à l'écriture des vecteurs : le premier
|
|
33
|
+
empêche la faute de se configurer, le second empêche un chemin de code de
|
|
34
|
+
la contourner.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import uuid
|
|
38
|
+
from decimal import Decimal
|
|
39
|
+
|
|
40
|
+
from sqlalchemy import select
|
|
41
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
42
|
+
|
|
43
|
+
from app.ai.capabilities import ModelCapability
|
|
44
|
+
from app.ai.embeddings import EmbeddingModelChangeRefused, assert_embedding_model_allowed
|
|
45
|
+
from app.ai.registry import ModelRegistry, default_registry
|
|
46
|
+
from app.ai.tasks import OutputRegime, effective_regime
|
|
47
|
+
from app.models.ai import AiModel, AiPolicy, AiProvider
|
|
48
|
+
|
|
49
|
+
HOSTING_SELF = "self_hosted"
|
|
50
|
+
HOSTING_THIRD_PARTY = "third_party"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AiConfigurationError(ValueError):
|
|
54
|
+
"""Écriture refusée — toutes les fautes trouvées sont rassemblées."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, errors: list[str]) -> None:
|
|
57
|
+
self.errors = errors
|
|
58
|
+
super().__init__(" ; ".join(errors))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _tarif(valeur: float | None) -> Decimal | None:
|
|
62
|
+
"""Convertit un tarif en `Decimal` sans passer par la binaire du flottant."""
|
|
63
|
+
return None if valeur is None else Decimal(str(valeur))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def register_provider(
|
|
67
|
+
db: AsyncSession,
|
|
68
|
+
*,
|
|
69
|
+
tenant_id: uuid.UUID,
|
|
70
|
+
key: str,
|
|
71
|
+
registry_key: str,
|
|
72
|
+
label: str,
|
|
73
|
+
declared_by_login: str,
|
|
74
|
+
hosting: str | None = None,
|
|
75
|
+
endpoint_url: str | None = None,
|
|
76
|
+
credential_env: str | None = None,
|
|
77
|
+
config: dict | None = None,
|
|
78
|
+
registry: ModelRegistry | None = None,
|
|
79
|
+
) -> AiProvider:
|
|
80
|
+
"""Active un fournisseur sur un tenant.
|
|
81
|
+
|
|
82
|
+
`hosting` peut être déduit de l'indication du catalogue quand celle-ci
|
|
83
|
+
est nette (`self_hosted` ou `third_party`), mais **jamais** quand elle
|
|
84
|
+
vaut `unknown` — cas du point d'accès « custom », précisément celui où
|
|
85
|
+
deviner serait faux. Là, l'administrateur déclare.
|
|
86
|
+
"""
|
|
87
|
+
registry = registry or default_registry()
|
|
88
|
+
errors: list[str] = []
|
|
89
|
+
entry = registry.provider(registry_key)
|
|
90
|
+
if entry is None:
|
|
91
|
+
raise AiConfigurationError(
|
|
92
|
+
[
|
|
93
|
+
f"fournisseur '{registry_key}' inconnu du catalogue : ajoutez-le en "
|
|
94
|
+
"YAML (AI_MODEL_REGISTRY_DIR) avant de l'activer sur un tenant"
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
if hosting is None:
|
|
98
|
+
if entry.hosting_hint == "unknown":
|
|
99
|
+
errors.append(
|
|
100
|
+
f"le catalogue ne dit pas où '{registry_key}' est hébergé : "
|
|
101
|
+
"l'administrateur doit déclarer 'self_hosted' ou 'third_party'"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
hosting = entry.hosting_hint
|
|
105
|
+
elif hosting not in (HOSTING_SELF, HOSTING_THIRD_PARTY):
|
|
106
|
+
errors.append(
|
|
107
|
+
f"hébergement '{hosting}' inconnu : 'self_hosted' ou 'third_party'"
|
|
108
|
+
)
|
|
109
|
+
if not declared_by_login:
|
|
110
|
+
errors.append("une déclaration de fournisseur doit nommer son auteur")
|
|
111
|
+
if errors:
|
|
112
|
+
raise AiConfigurationError(errors)
|
|
113
|
+
|
|
114
|
+
provider = AiProvider(
|
|
115
|
+
tenant_id=tenant_id,
|
|
116
|
+
key=key,
|
|
117
|
+
registry_key=registry_key,
|
|
118
|
+
label=label,
|
|
119
|
+
hosting=hosting,
|
|
120
|
+
endpoint_url=endpoint_url,
|
|
121
|
+
credential_env=credential_env or entry.credential_env,
|
|
122
|
+
config=config,
|
|
123
|
+
declared_by_login=declared_by_login,
|
|
124
|
+
)
|
|
125
|
+
db.add(provider)
|
|
126
|
+
await db.flush()
|
|
127
|
+
return provider
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def register_model(
|
|
131
|
+
db: AsyncSession,
|
|
132
|
+
*,
|
|
133
|
+
tenant_id: uuid.UUID,
|
|
134
|
+
provider: AiProvider,
|
|
135
|
+
key: str,
|
|
136
|
+
registry_key: str,
|
|
137
|
+
data_leaves_infrastructure: bool,
|
|
138
|
+
declared_by_login: str,
|
|
139
|
+
label: str | None = None,
|
|
140
|
+
declaration_note: str | None = None,
|
|
141
|
+
cost_input_per_1k: float | None = None,
|
|
142
|
+
cost_output_per_1k: float | None = None,
|
|
143
|
+
cost_currency: str | None = None,
|
|
144
|
+
timeout_seconds: int | None = None,
|
|
145
|
+
registry: ModelRegistry | None = None,
|
|
146
|
+
) -> AiModel:
|
|
147
|
+
"""Enregistre un modèle **avec la déclaration de sortie de données**.
|
|
148
|
+
|
|
149
|
+
`data_leaves_infrastructure` est un argument obligatoire et sans valeur
|
|
150
|
+
par défaut : l'appelant ne peut pas l'oublier. C'est une déclaration
|
|
151
|
+
d'administrateur, datée et attribuée — le CMS ne l'infère pas, ne la
|
|
152
|
+
teste pas et ne la corrige pas, parce qu'un point d'accès « custom » sur
|
|
153
|
+
le réseau interne peut être un proxy vers un tiers et que rien dans une
|
|
154
|
+
requête HTTP ne permet de l'exclure.
|
|
155
|
+
"""
|
|
156
|
+
registry = registry or default_registry()
|
|
157
|
+
errors: list[str] = []
|
|
158
|
+
entry = registry.model(registry_key)
|
|
159
|
+
if entry is None:
|
|
160
|
+
raise AiConfigurationError(
|
|
161
|
+
[
|
|
162
|
+
f"modèle '{registry_key}' inconnu du catalogue : sans entrée, ni le "
|
|
163
|
+
"nom de runtime ni le protocole ne sont connus"
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
if entry.provider != provider.registry_key:
|
|
167
|
+
errors.append(
|
|
168
|
+
f"le modèle '{registry_key}' appartient au fournisseur "
|
|
169
|
+
f"'{entry.provider}', pas à '{provider.registry_key}'"
|
|
170
|
+
)
|
|
171
|
+
if provider.tenant_id != tenant_id:
|
|
172
|
+
errors.append("le fournisseur désigné appartient à un autre tenant")
|
|
173
|
+
if provider.hosting == HOSTING_THIRD_PARTY and not data_leaves_infrastructure:
|
|
174
|
+
errors.append(
|
|
175
|
+
f"le point d'accès '{provider.key}' est déclaré tiers : un modèle qu'il "
|
|
176
|
+
"sert ne peut pas être déclaré comme gardant les données dans "
|
|
177
|
+
"l'infrastructure"
|
|
178
|
+
)
|
|
179
|
+
provider_entry = registry.provider(provider.registry_key)
|
|
180
|
+
hint = provider_entry.hosting_hint if provider_entry is not None else "unknown"
|
|
181
|
+
if hint == "third_party" and not data_leaves_infrastructure and not declaration_note:
|
|
182
|
+
errors.append(
|
|
183
|
+
f"le catalogue indique '{provider.registry_key}' comme tiers et la "
|
|
184
|
+
"déclaration dit le contraire : cette contradiction doit être justifiée "
|
|
185
|
+
"par écrit (declaration_note) — le CMS ne peut pas la vérifier, il exige "
|
|
186
|
+
"qu'elle soit assumée"
|
|
187
|
+
)
|
|
188
|
+
if not declared_by_login:
|
|
189
|
+
errors.append(
|
|
190
|
+
"une déclaration de sortie de données doit nommer son auteur : c'est un "
|
|
191
|
+
"engagement, pas une case à cocher"
|
|
192
|
+
)
|
|
193
|
+
if errors:
|
|
194
|
+
raise AiConfigurationError(errors)
|
|
195
|
+
|
|
196
|
+
model = AiModel(
|
|
197
|
+
tenant_id=tenant_id,
|
|
198
|
+
provider_id=provider.id,
|
|
199
|
+
key=key,
|
|
200
|
+
registry_key=registry_key,
|
|
201
|
+
label=label or entry.label,
|
|
202
|
+
capability=entry.capability,
|
|
203
|
+
hosting=provider.hosting,
|
|
204
|
+
data_leaves_infrastructure=data_leaves_infrastructure,
|
|
205
|
+
declared_by_login=declared_by_login,
|
|
206
|
+
declaration_note=declaration_note,
|
|
207
|
+
embedding_dimensions=registry.embedding_dimensions(registry_key),
|
|
208
|
+
# `Decimal` et non `float` : un tarif est une somme d'argent, et la
|
|
209
|
+
# colonne est un `NUMERIC`. Convertir depuis la chaîne évite
|
|
210
|
+
# d'inscrire en base l'approximation binaire d'un flottant.
|
|
211
|
+
cost_input_per_1k=_tarif(cost_input_per_1k),
|
|
212
|
+
cost_output_per_1k=_tarif(cost_output_per_1k),
|
|
213
|
+
cost_currency=cost_currency,
|
|
214
|
+
timeout_seconds=timeout_seconds,
|
|
215
|
+
)
|
|
216
|
+
db.add(model)
|
|
217
|
+
await db.flush()
|
|
218
|
+
return model
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def set_policy(
|
|
222
|
+
db: AsyncSession,
|
|
223
|
+
*,
|
|
224
|
+
tenant_id: uuid.UUID,
|
|
225
|
+
task_key: str,
|
|
226
|
+
capability: ModelCapability,
|
|
227
|
+
updated_by_login: str,
|
|
228
|
+
model: AiModel | None = None,
|
|
229
|
+
fallback: AiModel | None = None,
|
|
230
|
+
site_id: uuid.UUID | None = None,
|
|
231
|
+
output_regime: OutputRegime | None = None,
|
|
232
|
+
allow_personal_data: bool = False,
|
|
233
|
+
allow_data_leaving: bool = False,
|
|
234
|
+
timeout_seconds: int | None = None,
|
|
235
|
+
max_attempts: int = 2,
|
|
236
|
+
enabled: bool = True,
|
|
237
|
+
) -> AiPolicy:
|
|
238
|
+
"""Pose (ou remplace) la politique d'une tâche, pour un tenant ou un site.
|
|
239
|
+
|
|
240
|
+
Le régime de sortie est **plafonné par le Core** : `effective_regime`
|
|
241
|
+
retient toujours le plus exigeant entre ce que le CDC impose à la tâche
|
|
242
|
+
et ce que le tenant demande. Un tenant peut donc exiger une validation
|
|
243
|
+
humaine là où le Core ne l'exigeait pas ; il ne peut pas la retirer.
|
|
244
|
+
"""
|
|
245
|
+
errors: list[str] = []
|
|
246
|
+
if model is not None:
|
|
247
|
+
if model.tenant_id != tenant_id:
|
|
248
|
+
errors.append("le modèle désigné appartient à un autre tenant")
|
|
249
|
+
if model.capability != capability:
|
|
250
|
+
errors.append(
|
|
251
|
+
f"le modèle '{model.key}' sert la capacité '{model.capability}', la "
|
|
252
|
+
f"politique porte '{capability}'"
|
|
253
|
+
)
|
|
254
|
+
if fallback is not None:
|
|
255
|
+
if fallback.tenant_id != tenant_id:
|
|
256
|
+
errors.append("le modèle de repli appartient à un autre tenant")
|
|
257
|
+
if fallback.capability != capability:
|
|
258
|
+
errors.append(
|
|
259
|
+
f"le repli '{fallback.key}' sert la capacité '{fallback.capability}', "
|
|
260
|
+
f"la politique porte '{capability}'"
|
|
261
|
+
)
|
|
262
|
+
if not updated_by_login:
|
|
263
|
+
errors.append("une politique IA doit nommer qui l'a posée")
|
|
264
|
+
if errors:
|
|
265
|
+
raise AiConfigurationError(errors)
|
|
266
|
+
|
|
267
|
+
# Garde-fou ADR-0004 : désigner un autre modèle d'embedding est une
|
|
268
|
+
# migration. Le refus est opposé **avant** l'écriture de la politique,
|
|
269
|
+
# sinon la configuration serait acceptée et la première indexation
|
|
270
|
+
# échouerait chez quelqu'un d'autre, plus tard.
|
|
271
|
+
if capability == "embedding" and model is not None:
|
|
272
|
+
dimensions = model.embedding_dimensions
|
|
273
|
+
if dimensions is None:
|
|
274
|
+
raise AiConfigurationError(
|
|
275
|
+
[
|
|
276
|
+
f"le modèle '{model.key}' ne déclare aucune dimension "
|
|
277
|
+
"d'embedding : il ne peut pas porter une politique de "
|
|
278
|
+
"vectorisation"
|
|
279
|
+
]
|
|
280
|
+
)
|
|
281
|
+
try:
|
|
282
|
+
await assert_embedding_model_allowed(
|
|
283
|
+
db, tenant_id=tenant_id, model_key=model.key, dimensions=dimensions
|
|
284
|
+
)
|
|
285
|
+
except EmbeddingModelChangeRefused as exc:
|
|
286
|
+
raise AiConfigurationError([str(exc)]) from exc
|
|
287
|
+
|
|
288
|
+
regime = effective_regime(task_key, capability, output_regime)
|
|
289
|
+
stmt = select(AiPolicy).where(
|
|
290
|
+
AiPolicy.tenant_id == tenant_id,
|
|
291
|
+
AiPolicy.task_key == task_key,
|
|
292
|
+
)
|
|
293
|
+
stmt = (
|
|
294
|
+
stmt.where(AiPolicy.site_id.is_(None))
|
|
295
|
+
if site_id is None
|
|
296
|
+
else stmt.where(AiPolicy.site_id == site_id)
|
|
297
|
+
)
|
|
298
|
+
policy = (await db.execute(stmt)).scalar_one_or_none()
|
|
299
|
+
if policy is None:
|
|
300
|
+
policy = AiPolicy(tenant_id=tenant_id, site_id=site_id, task_key=task_key)
|
|
301
|
+
db.add(policy)
|
|
302
|
+
policy.capability = capability
|
|
303
|
+
policy.model_id = model.id if model is not None else None
|
|
304
|
+
policy.fallback_model_id = fallback.id if fallback is not None else None
|
|
305
|
+
policy.output_regime = regime
|
|
306
|
+
policy.allow_personal_data = allow_personal_data
|
|
307
|
+
policy.allow_data_leaving = allow_data_leaving
|
|
308
|
+
policy.enabled = enabled
|
|
309
|
+
policy.timeout_seconds = timeout_seconds
|
|
310
|
+
policy.max_attempts = max(1, max_attempts)
|
|
311
|
+
policy.updated_by_login = updated_by_login
|
|
312
|
+
# `degrade` est la seule valeur acceptée par la base : aucune politique
|
|
313
|
+
# ne peut rendre un échec IA bloquant pour le Core (CDC §14.4).
|
|
314
|
+
policy.on_failure = "degrade"
|
|
315
|
+
await db.flush()
|
|
316
|
+
return policy
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
__all__ = [
|
|
320
|
+
"HOSTING_SELF",
|
|
321
|
+
"HOSTING_THIRD_PARTY",
|
|
322
|
+
"AiConfigurationError",
|
|
323
|
+
"register_model",
|
|
324
|
+
"register_provider",
|
|
325
|
+
"set_policy",
|
|
326
|
+
]
|
app/ai/breaker.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Disjoncteur par modèle — Epic E8 (CDC §14.1, §14.4).
|
|
2
|
+
|
|
3
|
+
**Ce que le disjoncteur protège, ce n'est pas le fournisseur : c'est le
|
|
4
|
+
CMS.** Quand un point d'accès ne répond plus, chaque appel coûte le délai
|
|
5
|
+
d'attente complet avant d'échouer. Sans disjoncteur, une page qui propose
|
|
6
|
+
trois textes alternatifs met trois fois ce délai à s'afficher — pour finir
|
|
7
|
+
par ne rien proposer de toute façon. Après quelques échecs consécutifs, on
|
|
8
|
+
cesse d'essayer : la fonctionnalité disparaît **immédiatement** de l'écran,
|
|
9
|
+
et c'est exactement ce que `on_failure: degrade` promet.
|
|
10
|
+
|
|
11
|
+
**En mémoire de processus, et c'est un choix.** Un disjoncteur partagé
|
|
12
|
+
(Redis) donnerait une vue commune à tous les travailleurs — au prix d'une
|
|
13
|
+
dépendance de plus sur le chemin d'un appel IA, et d'une question
|
|
14
|
+
désagréable : que fait le CMS quand le magasin du disjoncteur est lui-même
|
|
15
|
+
indisponible ? Ici, chaque processus constate la panne pour son compte,
|
|
16
|
+
au pire une fois par travailleur, et rien de tout cela ne peut empêcher le
|
|
17
|
+
Core de fonctionner. La perte d'information à un redémarrage est sans
|
|
18
|
+
conséquence : le premier appel rouvrira le circuit ou le refermera.
|
|
19
|
+
|
|
20
|
+
**La clé est `(tenant, modèle)`, jamais le seul modèle.** Deux
|
|
21
|
+
collectivités peuvent employer le même modèle chez deux fournisseurs
|
|
22
|
+
différents — ou le même fournisseur avec des quotas différents. Faire
|
|
23
|
+
tomber la fonction de l'une parce que le compte de l'autre est épuisé
|
|
24
|
+
aurait été une fuite d'état entre tenants, de la même famille que celles
|
|
25
|
+
qu'E6 et E7 refusent ailleurs.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import time
|
|
29
|
+
import uuid
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _State:
|
|
36
|
+
failures: int = 0
|
|
37
|
+
opened_at: float | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class CircuitBreaker:
|
|
42
|
+
"""Compte les échecs consécutifs et ouvre le circuit au-delà du seuil."""
|
|
43
|
+
|
|
44
|
+
failure_threshold: int = 3
|
|
45
|
+
reset_after_seconds: float = 60.0
|
|
46
|
+
# Source de temps injectable : un test de disjoncteur qui attendrait
|
|
47
|
+
# soixante secondes ne serait pas exécuté, donc pas fiable.
|
|
48
|
+
time_source: Callable[[], float] = time.monotonic
|
|
49
|
+
_states: dict[tuple[uuid.UUID, str], _State] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def _now(self) -> float:
|
|
52
|
+
return float(self.time_source())
|
|
53
|
+
|
|
54
|
+
def is_open(self, tenant_id: uuid.UUID, model_key: str) -> bool:
|
|
55
|
+
"""Le circuit est-il ouvert *maintenant* ?
|
|
56
|
+
|
|
57
|
+
Referme de lui-même après `reset_after_seconds` : un fournisseur
|
|
58
|
+
rétabli ne doit pas rester coupé jusqu'à un redémarrage, sans quoi le
|
|
59
|
+
disjoncteur deviendrait une panne à son tour.
|
|
60
|
+
"""
|
|
61
|
+
state = self._states.get((tenant_id, model_key))
|
|
62
|
+
if state is None or state.opened_at is None:
|
|
63
|
+
return False
|
|
64
|
+
if self._now() - state.opened_at >= self.reset_after_seconds:
|
|
65
|
+
self._states.pop((tenant_id, model_key), None)
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def record_failure(self, tenant_id: uuid.UUID, model_key: str) -> None:
|
|
70
|
+
state = self._states.setdefault((tenant_id, model_key), _State())
|
|
71
|
+
state.failures += 1
|
|
72
|
+
if state.failures >= self.failure_threshold and state.opened_at is None:
|
|
73
|
+
state.opened_at = self._now()
|
|
74
|
+
|
|
75
|
+
def record_success(self, tenant_id: uuid.UUID, model_key: str) -> None:
|
|
76
|
+
"""Un succès efface le compteur : ce sont les échecs *consécutifs*
|
|
77
|
+
qui signalent une panne, pas un total cumulé depuis le démarrage."""
|
|
78
|
+
self._states.pop((tenant_id, model_key), None)
|
|
79
|
+
|
|
80
|
+
def reset(self) -> None:
|
|
81
|
+
self._states.clear()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["CircuitBreaker"]
|
app/ai/capabilities.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Capacités typées du Model Registry — Epic E8 (CDC §14.1, §14.2).
|
|
2
|
+
|
|
3
|
+
**Cinq capacités, pas une interface fourre-tout.** Le CDC nomme
|
|
4
|
+
`LLM · Embedding · Vision · OCR · STT` et le fait exprès : ces cinq
|
|
5
|
+
traitements n'ont ni les mêmes entrées, ni les mêmes sorties, ni les mêmes
|
|
6
|
+
unités de coût, ni les mêmes conséquences en cas de panne. Une interface
|
|
7
|
+
unique `AIProvider.call(payload) -> payload` aurait rendu ces différences
|
|
8
|
+
invisibles au typage, donc reportées à l'exécution : un modèle d'embedding
|
|
9
|
+
appelé pour rédiger un texte alternatif n'aurait échoué qu'au moment de
|
|
10
|
+
l'appel, chez un fournisseur, avec des données déjà sorties. Ici, chaque
|
|
11
|
+
capacité a son port (`app/ai/ports.py`) et sa paire requête/résultat.
|
|
12
|
+
|
|
13
|
+
**Deux vocabulaires existent déjà, et il fallait les raccorder plutôt que
|
|
14
|
+
les faire coexister.** L'Epic E7 a figé, dans `app/plugins/manifest.py`, un
|
|
15
|
+
vocabulaire *fermé* de ce qu'un plugin peut demander — `text_generation`,
|
|
16
|
+
`embedding`, `vision`, `transcription`, `classification` — en écrivant
|
|
17
|
+
explicitement que la liste « s'étendra avec E8, de façon additive ». Le
|
|
18
|
+
présent module ne crée donc pas un second vocabulaire concurrent : il pose
|
|
19
|
+
les capacités **techniques** d'un modèle et déclare, dans
|
|
20
|
+
`PLUGIN_CAPABILITY_TO_MODEL_CAPABILITY`, la correspondance vers celles-ci.
|
|
21
|
+
Les deux listes ne se recouvrent pas à moitié, elles se traduisent
|
|
22
|
+
entièrement — et un test de recette vérifie mécaniquement que **toute**
|
|
23
|
+
capacité déclarable par un plugin a une traduction.
|
|
24
|
+
|
|
25
|
+
Deux points de cette correspondance méritent d'être dits plutôt que subis :
|
|
26
|
+
|
|
27
|
+
- `classification` → `llm`. Classer une demande de saisine dans une famille
|
|
28
|
+
de service, c'est demander à un modèle de langue de choisir une étiquette.
|
|
29
|
+
Inventer une capacité `classifier` distincte aurait supposé des modèles
|
|
30
|
+
dédiés que le registre n'a aucune raison de distinguer aujourd'hui.
|
|
31
|
+
- `transcription` → `stt`. Même traitement, deux noms : celui du CDC pour le
|
|
32
|
+
registre, celui d'E7 pour ce qu'un plugin déclare. La traduction est ici,
|
|
33
|
+
en un seul endroit.
|
|
34
|
+
|
|
35
|
+
**`ocr` est ajouté au vocabulaire d'E7 par cet Epic**, exactement comme E7
|
|
36
|
+
l'avait prévu : l'extraction documentaire est nommée par le CDC §14.9 comme
|
|
37
|
+
une fonction de priorité 1, et sans capacité `ocr` déclarable, un plugin
|
|
38
|
+
d'extraction n'aurait pas eu de mot pour dire ce qu'il fait — il aurait
|
|
39
|
+
déclaré `vision`, c'est-à-dire autre chose.
|
|
40
|
+
|
|
41
|
+
**Le type `AiCapability` n'est importé qu'à la vérification de types, pas à
|
|
42
|
+
l'exécution.** Deux raisons, dans cet ordre. La première est un sens de
|
|
43
|
+
dépendance : le socle IA ne doit pas dépendre du Plugin SDK — c'est le SDK
|
|
44
|
+
qui s'adresse au socle (`app/plugins/ai.py`), jamais l'inverse. La seconde
|
|
45
|
+
est mécanique : `app.plugins` importe `app.plugins.ai`, qui importe le
|
|
46
|
+
Provider Manager, qui passe par ce module ; un import d'exécution refermerait
|
|
47
|
+
la boucle et rendrait l'ordre des imports significatif. Le prix à payer
|
|
48
|
+
serait que les deux vocabulaires puissent diverger sans que rien ne le
|
|
49
|
+
signale — c'est pourquoi un test de recette compare mécaniquement les clés de
|
|
50
|
+
`PLUGIN_CAPABILITY_TO_MODEL_CAPABILITY` à `get_args(AiCapability)`.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from typing import TYPE_CHECKING, Literal, get_args
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING: # pragma: no cover - cf. docstring de module
|
|
58
|
+
from app.plugins.manifest import AiCapability
|
|
59
|
+
|
|
60
|
+
# Ce qu'un **modèle** sait faire. Vocabulaire du CDC §14.2, fermé : le
|
|
61
|
+
# Provider Manager route une capacité vers un port typé, et une chaîne libre
|
|
62
|
+
# ne se route nulle part.
|
|
63
|
+
ModelCapability = Literal["llm", "embedding", "vision", "ocr", "stt"]
|
|
64
|
+
|
|
65
|
+
ALL_MODEL_CAPABILITIES: tuple[ModelCapability, ...] = get_args(ModelCapability)
|
|
66
|
+
|
|
67
|
+
# La traduction, en un seul endroit (cf. docstring de module).
|
|
68
|
+
PLUGIN_CAPABILITY_TO_MODEL_CAPABILITY: dict[AiCapability, ModelCapability] = {
|
|
69
|
+
"text_generation": "llm",
|
|
70
|
+
"classification": "llm",
|
|
71
|
+
"embedding": "embedding",
|
|
72
|
+
"vision": "vision",
|
|
73
|
+
"ocr": "ocr",
|
|
74
|
+
"transcription": "stt",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# Les capacités dont la sortie est **structurelle** et non éditoriale : un
|
|
78
|
+
# vecteur ou un texte extrait d'un document ne se « valide » pas, il se
|
|
79
|
+
# recalcule. Elles relèvent du régime `none` (cf. `app/ai/tasks.py`), et
|
|
80
|
+
# c'est la seule raison pour laquelle cette liste existe ici : le régime de
|
|
81
|
+
# sortie par défaut d'une tâche inconnue en dépend.
|
|
82
|
+
STRUCTURAL_CAPABILITIES: frozenset[ModelCapability] = frozenset({"embedding", "ocr"})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def model_capability_for_plugin(capability: AiCapability) -> ModelCapability:
|
|
86
|
+
"""Traduit une capacité déclarée par un plugin (E7) en capacité de modèle.
|
|
87
|
+
|
|
88
|
+
Lève `KeyError` sur une capacité inconnue plutôt que de retomber sur
|
|
89
|
+
`llm` : une capacité ajoutée à E7 et oubliée ici doit se voir, pas se
|
|
90
|
+
faire router au hasard vers un modèle de langue.
|
|
91
|
+
"""
|
|
92
|
+
return PLUGIN_CAPABILITY_TO_MODEL_CAPABILITY[capability]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"ALL_MODEL_CAPABILITIES",
|
|
97
|
+
"PLUGIN_CAPABILITY_TO_MODEL_CAPABILITY",
|
|
98
|
+
"STRUCTURAL_CAPABILITIES",
|
|
99
|
+
"ModelCapability",
|
|
100
|
+
"model_capability_for_plugin",
|
|
101
|
+
]
|