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.
Files changed (220) hide show
  1. app/__init__.py +0 -0
  2. app/ai/__init__.py +58 -0
  3. app/ai/administration.py +326 -0
  4. app/ai/breaker.py +84 -0
  5. app/ai/capabilities.py +101 -0
  6. app/ai/catalogue/models.yaml +178 -0
  7. app/ai/catalogue/providers.yaml +93 -0
  8. app/ai/embeddings.py +470 -0
  9. app/ai/journal.py +165 -0
  10. app/ai/manager.py +727 -0
  11. app/ai/policies.py +320 -0
  12. app/ai/ports.py +302 -0
  13. app/ai/providers/__init__.py +17 -0
  14. app/ai/providers/loopback.py +138 -0
  15. app/ai/registry.py +379 -0
  16. app/ai/runtime.py +49 -0
  17. app/ai/schemas.py +358 -0
  18. app/ai/sovereignty.py +144 -0
  19. app/ai/suggestions.py +265 -0
  20. app/ai/tasks.py +232 -0
  21. app/ai/vector.py +82 -0
  22. app/ai_editorial/__init__.py +74 -0
  23. app/ai_editorial/alt_text.py +220 -0
  24. app/ai_editorial/chunking.py +136 -0
  25. app/ai_editorial/extraction.py +232 -0
  26. app/ai_editorial/obsolescence.py +596 -0
  27. app/ai_editorial/rag.py +709 -0
  28. app/ai_editorial/references.py +155 -0
  29. app/ai_editorial/schemas.py +305 -0
  30. app/ai_editorial/seo.py +450 -0
  31. app/alembic/env.py +59 -0
  32. app/alembic/script.py.mako +24 -0
  33. app/alembic/versions/0001_initial_tenant_site.py +58 -0
  34. app/alembic/versions/0002_content_type_engine.py +76 -0
  35. app/alembic/versions/0003_page_builder.py +67 -0
  36. app/alembic/versions/0004_listing_engine.py +117 -0
  37. app/alembic/versions/0005_lifecycle_workflow.py +139 -0
  38. app/alembic/versions/0006_rgpd_audit_cookies.py +251 -0
  39. app/alembic/versions/0007_rbac_acl_extranet.py +291 -0
  40. app/alembic/versions/0008_plugin_sdk.py +111 -0
  41. app/alembic/versions/0009_ai_socle.py +469 -0
  42. app/alembic/versions/0010_forms_notifications_discussions.py +229 -0
  43. app/alembic/versions/0011_recherche_complete.py +229 -0
  44. app/alembic/versions/0012_comptes_usagers.py +431 -0
  45. app/alembic/versions/0013_conformite_generee.py +190 -0
  46. app/alembic/versions/0014_multisite_propagation.py +307 -0
  47. app/alembic/versions/0015_json_none_backfill.py +58 -0
  48. app/api/__init__.py +0 -0
  49. app/api/access.py +511 -0
  50. app/api/account.py +775 -0
  51. app/api/ai_admin.py +597 -0
  52. app/api/ai_editorial.py +789 -0
  53. app/api/auth.py +577 -0
  54. app/api/compliance.py +791 -0
  55. app/api/content_types.py +559 -0
  56. app/api/cookies.py +435 -0
  57. app/api/discussions.py +206 -0
  58. app/api/end_user_admin.py +145 -0
  59. app/api/exports.py +158 -0
  60. app/api/forms.py +248 -0
  61. app/api/lifecycle.py +492 -0
  62. app/api/listings.py +194 -0
  63. app/api/multisite.py +658 -0
  64. app/api/pages.py +600 -0
  65. app/api/personal_files.py +134 -0
  66. app/api/plugins.py +244 -0
  67. app/api/public_forms.py +515 -0
  68. app/api/rgpd.py +479 -0
  69. app/api/search.py +135 -0
  70. app/api/search_dictionaries.py +379 -0
  71. app/api/staff_users.py +424 -0
  72. app/bundled_plugins/__init__.py +55 -0
  73. app/bundled_plugins/cartography.py +264 -0
  74. app/bundled_plugins/comments.py +37 -0
  75. app/bundled_plugins/sve.py +445 -0
  76. app/cli/__init__.py +34 -0
  77. app/cli/docker.py +241 -0
  78. app/cli/main.py +299 -0
  79. app/cli/manage.py +129 -0
  80. app/cli/migrate.py +68 -0
  81. app/cli/templates.py +143 -0
  82. app/compliance/__init__.py +30 -0
  83. app/compliance/accessibility.py +479 -0
  84. app/compliance/attributions.py +152 -0
  85. app/compliance/disclaimer.py +111 -0
  86. app/compliance/policy.py +114 -0
  87. app/compliance/privacy_policy.py +253 -0
  88. app/compliance/referential.py +330 -0
  89. app/compliance/referentials/rgaa-4.1.2.yaml +657 -0
  90. app/compliance/runtime.py +43 -0
  91. app/compliance/schemas.py +218 -0
  92. app/content/__init__.py +0 -0
  93. app/content/conditions.py +257 -0
  94. app/content/fields.py +555 -0
  95. app/content/geo.py +247 -0
  96. app/content/query.py +226 -0
  97. app/content/schemas.py +155 -0
  98. app/core/__init__.py +0 -0
  99. app/core/config.py +130 -0
  100. app/core/database.py +20 -0
  101. app/discussions/__init__.py +6 -0
  102. app/discussions/service.py +137 -0
  103. app/discussions/threads.py +114 -0
  104. app/end_users/__init__.py +14 -0
  105. app/end_users/accounts.py +466 -0
  106. app/end_users/export.py +358 -0
  107. app/end_users/identity.py +144 -0
  108. app/end_users/lifecycle.py +586 -0
  109. app/end_users/oidc.py +550 -0
  110. app/end_users/policy.py +239 -0
  111. app/end_users/prefill.py +94 -0
  112. app/end_users/schemas.py +281 -0
  113. app/end_users/sessions.py +167 -0
  114. app/end_users/space.py +382 -0
  115. app/forms/__init__.py +22 -0
  116. app/forms/captcha.py +246 -0
  117. app/forms/config.py +255 -0
  118. app/forms/export.py +320 -0
  119. app/forms/prefill.py +127 -0
  120. app/forms/submissions.py +553 -0
  121. app/forms/validation.py +223 -0
  122. app/lifecycle/__init__.py +1 -0
  123. app/lifecycle/engine.py +345 -0
  124. app/lifecycle/notifications.py +114 -0
  125. app/lifecycle/registry.py +332 -0
  126. app/lifecycle/rules.py +471 -0
  127. app/lifecycle/schemas.py +104 -0
  128. app/lifecycle/settings.py +355 -0
  129. app/lifecycle/workflow.py +202 -0
  130. app/listing/__init__.py +0 -0
  131. app/listing/config.py +366 -0
  132. app/listing/filters.py +378 -0
  133. app/listing/indexing.py +419 -0
  134. app/listing/resolver.py +320 -0
  135. app/listing/rss.py +72 -0
  136. app/listing/schemas.py +32 -0
  137. app/main.py +187 -0
  138. app/models/__init__.py +86 -0
  139. app/models/access.py +84 -0
  140. app/models/accessibility.py +142 -0
  141. app/models/ai.py +524 -0
  142. app/models/content.py +193 -0
  143. app/models/discussion.py +86 -0
  144. app/models/end_user.py +357 -0
  145. app/models/end_user_space.py +138 -0
  146. app/models/forms.py +146 -0
  147. app/models/lifecycle.py +60 -0
  148. app/models/multisite.py +239 -0
  149. app/models/notification.py +134 -0
  150. app/models/page_builder.py +65 -0
  151. app/models/personal_file.py +119 -0
  152. app/models/plugin.py +99 -0
  153. app/models/rgpd.py +225 -0
  154. app/models/search.py +157 -0
  155. app/models/search_index.py +189 -0
  156. app/models/staff.py +178 -0
  157. app/models/tenant.py +120 -0
  158. app/multisite/__init__.py +14 -0
  159. app/multisite/factory.py +513 -0
  160. app/multisite/guards.py +129 -0
  161. app/multisite/hashing.py +126 -0
  162. app/multisite/projection.py +190 -0
  163. app/multisite/replication.py +626 -0
  164. app/multisite/schema_lock.py +142 -0
  165. app/multisite/schemas.py +145 -0
  166. app/multisite/sharing.py +305 -0
  167. app/multisite/taxonomy.py +234 -0
  168. app/notifications/__init__.py +14 -0
  169. app/notifications/bridge.py +111 -0
  170. app/notifications/engine.py +464 -0
  171. app/notifications/templates.py +230 -0
  172. app/page_builder/__init__.py +0 -0
  173. app/page_builder/blocks.py +367 -0
  174. app/page_builder/columns.py +36 -0
  175. app/page_builder/content_source.py +239 -0
  176. app/page_builder/layout.py +48 -0
  177. app/page_builder/render.py +115 -0
  178. app/page_builder/schemas.py +143 -0
  179. app/page_builder/validation.py +318 -0
  180. app/plugins/__init__.py +275 -0
  181. app/plugins/ai.py +225 -0
  182. app/plugins/catalogue.py +68 -0
  183. app/plugins/composition.py +351 -0
  184. app/plugins/installation.py +296 -0
  185. app/plugins/manifest.py +603 -0
  186. app/plugins/runtime.py +53 -0
  187. app/plugins/schemas.py +96 -0
  188. app/rbac/__init__.py +53 -0
  189. app/rbac/acl.py +551 -0
  190. app/rbac/identity.py +277 -0
  191. app/rbac/mfa.py +132 -0
  192. app/rbac/passwords.py +226 -0
  193. app/rbac/policy.py +98 -0
  194. app/rbac/providers.py +181 -0
  195. app/rbac/roles.py +388 -0
  196. app/rbac/schemas.py +370 -0
  197. app/rbac/sessions.py +188 -0
  198. app/rgpd/__init__.py +1 -0
  199. app/rgpd/actor.py +104 -0
  200. app/rgpd/cookies.py +495 -0
  201. app/rgpd/detection.py +286 -0
  202. app/rgpd/exclusion.py +101 -0
  203. app/rgpd/journal.py +383 -0
  204. app/rgpd/policy.py +109 -0
  205. app/rgpd/processing.py +196 -0
  206. app/rgpd/purge.py +184 -0
  207. app/rgpd/schemas.py +276 -0
  208. app/search/__init__.py +31 -0
  209. app/search/dictionaries.py +260 -0
  210. app/search/engine.py +869 -0
  211. app/search/normalize.py +100 -0
  212. app/search/query.py +351 -0
  213. app/search/reindex.py +129 -0
  214. app/search/schemas.py +82 -0
  215. app/storage/__init__.py +7 -0
  216. app/storage/personal_files.py +418 -0
  217. teuthis_cms-0.1.0.dist-info/METADATA +19 -0
  218. teuthis_cms-0.1.0.dist-info/RECORD +220 -0
  219. teuthis_cms-0.1.0.dist-info/WHEEL +4 -0
  220. 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
+ """
@@ -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
+ ]