collective.aitools 1.0.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 (113) hide show
  1. collective/aitools/AGENTS.md +289 -0
  2. collective/aitools/__init__.py +14 -0
  3. collective/aitools/backfill.py +110 -0
  4. collective/aitools/browser/__init__.py +0 -0
  5. collective/aitools/browser/assistant.py +66 -0
  6. collective/aitools/browser/configure.zcml +57 -0
  7. collective/aitools/browser/help_me_write.py +70 -0
  8. collective/aitools/browser/recognize_image.py +57 -0
  9. collective/aitools/browser/static/aitools-remote.min.js +2 -0
  10. collective/aitools/browser/static/aitools.min.js +2 -0
  11. collective/aitools/browser/static/chunks/46.17392fd62e1893d8f4e0.min.js +2 -0
  12. collective/aitools/browser/static/chunks/46.17392fd62e1893d8f4e0.min.js.map +1 -0
  13. collective/aitools/browser/static/chunks/692.cd4653742130a6d95d03.min.js +12 -0
  14. collective/aitools/browser/static/chunks/692.cd4653742130a6d95d03.min.js.map +1 -0
  15. collective/aitools/browser/static/chunks/709.828f420ec7e88f982470.min.js +2 -0
  16. collective/aitools/browser/static/chunks/709.828f420ec7e88f982470.min.js.map +1 -0
  17. collective/aitools/browser/static/chunks/87.2eb8178be9c49f8e5ae5.min.js +2 -0
  18. collective/aitools/browser/static/chunks/87.2eb8178be9c49f8e5ae5.min.js.map +1 -0
  19. collective/aitools/browser/suggest_tags.py +53 -0
  20. collective/aitools/browser/summarize.py +53 -0
  21. collective/aitools/browser/templates/assistant_viewlet.pt +7 -0
  22. collective/aitools/browser/templates/help_me_write_viewlet.pt +8 -0
  23. collective/aitools/browser/templates/recognize_image_viewlet.pt +8 -0
  24. collective/aitools/browser/templates/suggest_tags_viewlet.pt +8 -0
  25. collective/aitools/browser/templates/summarize_viewlet.pt +8 -0
  26. collective/aitools/capabilities/__init__.py +18 -0
  27. collective/aitools/capabilities/base.py +76 -0
  28. collective/aitools/capabilities/configure.zcml +24 -0
  29. collective/aitools/capabilities/propose_draft.py +250 -0
  30. collective/aitools/capabilities/search_site.py +68 -0
  31. collective/aitools/capabilities/types.py +151 -0
  32. collective/aitools/capabilities/where_to_add.py +103 -0
  33. collective/aitools/configure.zcml +30 -0
  34. collective/aitools/content/__init__.py +0 -0
  35. collective/aitools/controlpanels/__init__.py +0 -0
  36. collective/aitools/controlpanels/ai_tools.py +36 -0
  37. collective/aitools/controlpanels/configure.zcml +22 -0
  38. collective/aitools/dependencies.zcml +8 -0
  39. collective/aitools/indexers/__init__.py +0 -0
  40. collective/aitools/indexers/configure.zcml +7 -0
  41. collective/aitools/interfaces.py +312 -0
  42. collective/aitools/locales/__init__.py +0 -0
  43. collective/aitools/locales/__main__.py +69 -0
  44. collective/aitools/locales/collective.aitools.pot +239 -0
  45. collective/aitools/locales/de/LC_MESSAGES/collective.aitools.mo +0 -0
  46. collective/aitools/locales/de/LC_MESSAGES/collective.aitools.po +236 -0
  47. collective/aitools/locales/en/LC_MESSAGES/collective.aitools.mo +0 -0
  48. collective/aitools/locales/en/LC_MESSAGES/collective.aitools.po +236 -0
  49. collective/aitools/locales/es/LC_MESSAGES/collective.aitools.mo +0 -0
  50. collective/aitools/locales/es/LC_MESSAGES/collective.aitools.po +236 -0
  51. collective/aitools/locales/it/LC_MESSAGES/collective.aitools.mo +0 -0
  52. collective/aitools/locales/it/LC_MESSAGES/collective.aitools.po +236 -0
  53. collective/aitools/locales/pt_BR/LC_MESSAGES/collective.aitools.mo +0 -0
  54. collective/aitools/locales/pt_BR/LC_MESSAGES/collective.aitools.po +236 -0
  55. collective/aitools/permissions.zcml +5 -0
  56. collective/aitools/profiles/default/browserlayer.xml +6 -0
  57. collective/aitools/profiles/default/catalog.xml +13 -0
  58. collective/aitools/profiles/default/controlpanel.xml +21 -0
  59. collective/aitools/profiles/default/diff_tool.xml +6 -0
  60. collective/aitools/profiles/default/metadata.xml +7 -0
  61. collective/aitools/profiles/default/registry/.gitkeep +0 -0
  62. collective/aitools/profiles/default/registry/main.xml +10 -0
  63. collective/aitools/profiles/default/registry/resources.xml +18 -0
  64. collective/aitools/profiles/default/repositorytool.xml +6 -0
  65. collective/aitools/profiles/default/rolemap.xml +6 -0
  66. collective/aitools/profiles/default/types/.gitkeep +0 -0
  67. collective/aitools/profiles/default/types.xml +10 -0
  68. collective/aitools/profiles/initial/metadata.xml +4 -0
  69. collective/aitools/profiles/uninstall/browserlayer.xml +6 -0
  70. collective/aitools/profiles/uninstall/registry.xml +10 -0
  71. collective/aitools/profiles.zcml +41 -0
  72. collective/aitools/serializers/__init__.py +0 -0
  73. collective/aitools/serializers/configure.zcml +10 -0
  74. collective/aitools/serializers/summary.py +10 -0
  75. collective/aitools/services/__init__.py +0 -0
  76. collective/aitools/services/assistant.py +187 -0
  77. collective/aitools/services/configure.zcml +59 -0
  78. collective/aitools/services/help_me_write.py +79 -0
  79. collective/aitools/services/recognize_image.py +65 -0
  80. collective/aitools/services/suggest_tags.py +55 -0
  81. collective/aitools/services/summarize.py +55 -0
  82. collective/aitools/setuphandlers/__init__.py +17 -0
  83. collective/aitools/setuphandlers/examplecontent/.gitkeep +0 -0
  84. collective/aitools/setuphandlers/examplecontent/content/__metadata__.json +40 -0
  85. collective/aitools/setuphandlers/examplecontent/content/a58ccead718140c1baa98d43595fc3e6/data.json +56 -0
  86. collective/aitools/setuphandlers/examplecontent/content/a58ccead718140c1baa98d43595fc3e6/image/plone-foundation.png +0 -0
  87. collective/aitools/setuphandlers/examplecontent/content/a720393b3c0240e5bd27c43fcd2cfd1e/data.json +98 -0
  88. collective/aitools/setuphandlers/examplecontent/content/plone_site_root/data.json +267 -0
  89. collective/aitools/setuphandlers/examplecontent/principals.json +4 -0
  90. collective/aitools/setuphandlers/examplecontent/redirects.json +1 -0
  91. collective/aitools/setuphandlers/examplecontent/relations.json +1 -0
  92. collective/aitools/setuphandlers/examplecontent/translations.json +1 -0
  93. collective/aitools/setuphandlers/initial.py +16 -0
  94. collective/aitools/testing.py +49 -0
  95. collective/aitools/tools/__init__.py +13 -0
  96. collective/aitools/tools/assistant.py +124 -0
  97. collective/aitools/tools/base.py +75 -0
  98. collective/aitools/tools/configure.zcml +35 -0
  99. collective/aitools/tools/help_me_write.py +83 -0
  100. collective/aitools/tools/recognize_image.py +131 -0
  101. collective/aitools/tools/suggest_tags.py +102 -0
  102. collective/aitools/tools/summarize.py +53 -0
  103. collective/aitools/tools/text.py +108 -0
  104. collective/aitools/upgrades/__init__.py +0 -0
  105. collective/aitools/upgrades/configure.zcml +21 -0
  106. collective/aitools/vocabularies/__init__.py +0 -0
  107. collective/aitools/vocabularies/configure.zcml +5 -0
  108. collective_aitools-1.0.0.dist-info/METADATA +155 -0
  109. collective_aitools-1.0.0.dist-info/RECORD +113 -0
  110. collective_aitools-1.0.0.dist-info/WHEEL +4 -0
  111. collective_aitools-1.0.0.dist-info/entry_points.txt +2 -0
  112. collective_aitools-1.0.0.dist-info/licenses/LICENSE.GPL +339 -0
  113. collective_aitools-1.0.0.dist-info/licenses/LICENSE.md +15 -0
@@ -0,0 +1,289 @@
1
+ # AGENTS.md — collective.aitools package source
2
+
3
+ Notes for the add-on source itself. Read [`../../../AGENTS.md`](../../../AGENTS.md)
4
+ (backend) and the repo root `AGENTS.md` first. **Update this file whenever you
5
+ add or change code under `src/collective/aitools/`** — that is the maintenance
6
+ rule from the backend guide, applied here.
7
+
8
+ ## The AI tools framework
9
+
10
+ The product's features are **AI tools**: each is a named utility providing
11
+ [`IAITool`](interfaces.py), enabled/configured from the "AI Tools" control
12
+ panel, and reaching a model through the `collective.aisettings` `IAIService`
13
+ utility (never an LLM SDK directly). When no model is configured the tool
14
+ passes `model=None` so aisettings resolves one by capability.
15
+
16
+ > Don't confuse our `collective.aitools.interfaces.IAITool` (a high-level
17
+ > editor/visitor feature) with `collective.aisettings`'s `IAITool` (a
18
+ > pydantic-ai function-calling tool offered to an agent mid-run). Different
19
+ > concept, same short name, different package.
20
+
21
+ ### Anatomy of a tool
22
+
23
+ ```
24
+ tools/
25
+ ├── base.py ← AITool base class + get_tool / get_tools / get_tools_settings
26
+ ├── recognize_image.py ← RecognizeImageTool (vision → IAIService.analyze_image)
27
+ ├── help_me_write.py ← HelpMeWriteTool (completion → IAIService.chat)
28
+ ├── summarize.py ← SummarizeTool (completion → IAIService.chat)
29
+ ├── suggest_tags.py ← SuggestTagsTool (completion, vocabulary-aware)
30
+ ├── assistant.py ← AssistantTool (agentic chat: IAIService.tool_call loop) — see below
31
+ └── configure.zcml ← one <utility provides=IAITool name=...> per tool
32
+ ```
33
+
34
+ Most tools do a single model call. **`assistant`** is different: it runs a
35
+ tool-calling *loop* and dispatches to **capabilities** (`capabilities/`) — see
36
+ the "The Assistant (agentic chat) + capabilities" section below.
37
+
38
+ `AITool` (in [`tools/base.py`](tools/base.py)) reads the registry via the
39
+ `<name>_enabled` / `<name>_model` convention, exposes `is_enabled()`,
40
+ `get_model()`, `available_for(context)`, and `self.service` (the
41
+ `IAIService` utility). Subclasses set `name` / `title` / `description` and
42
+ implement `run(context, **kwargs)`. The public `run` should early-return via
43
+ `self._skip_if_disabled()`.
44
+
45
+ How tools are *invoked* (event subscriber, action/menu item, async cron) is
46
+ deliberately undecided — tools only expose `run`.
47
+
48
+ ### Adding a new tool — checklist
49
+
50
+ 1. Create `tools/<name>.py` with a subclass of `AITool` (set `name` to match
51
+ the utility name; implement `run`).
52
+ 2. Register it in [`tools/configure.zcml`](tools/configure.zcml) as a
53
+ `<utility ... provides="collective.aitools.interfaces.IAITool"
54
+ name="<name>" />`.
55
+ 3. Add `<name>_enabled` (Bool) and, if it uses a model, `<name>_model`
56
+ (TextLine) to [`IAIToolsSettings`](interfaces.py) — plus any extra config
57
+ fields the tool needs (e.g. `recognize_image_prompt`).
58
+ 4. The control panel ([`controlpanels/ai_tools.py`](controlpanels/ai_tools.py))
59
+ renders the new fields automatically (it's bound to the whole schema). No
60
+ change needed there.
61
+ 5. The registry records are created by `<records interface=...IAIToolsSettings />`
62
+ in [`profiles/default/registry/main.xml`](profiles/default/registry/main.xml) —
63
+ already wired, but **bump the profile version in
64
+ `profiles/default/metadata.xml` and add an upgrade step under `upgrades/`**
65
+ so existing sites get the new records.
66
+ 6. Add tests under `backend/tests/tools/` (registration, enable/disable,
67
+ model resolution, and a `run` test with the `fake_service` fixture pattern).
68
+ 7. Update this file.
69
+
70
+ ## The Assistant (agentic chat) + capabilities
71
+
72
+ The `assistant` tool ([`tools/assistant.py`](tools/assistant.py)) is a chat
73
+ assistant for authenticated users. Unlike the other tools it is **agentic**: it
74
+ hands the model a set of *capabilities* (function/tool-calling schemas), runs
75
+ whichever the model asks for, feeds the results back, and repeats until the
76
+ model produces a final answer (capped at `MAX_STEPS`). It reaches the model
77
+ through `IAIService.tool_call(messages, tools, …)` — the OpenAI-style
78
+ tool-calling surface — never an SDK directly.
79
+
80
+ ### Capabilities
81
+
82
+ A **capability** is the assistant's equivalent of a tool, but exposed *to the
83
+ model* as a callable function. Each is a named utility providing
84
+ [`IAIAssistantCapability`](interfaces.py), under `capabilities/`:
85
+
86
+ ```
87
+ capabilities/
88
+ ├── base.py ← AICapability base + get_capability / get_capabilities / manifest_for
89
+ ├── search_site.py ← search_site (permission View; read-only catalog search)
90
+ ├── where_to_add.py ← where_to_add (permission cmf.ModifyPortalContent; suggest folders)
91
+ ├── propose_draft.py ← propose_draft (permission cmf.ModifyPortalContent; AI-drafts, proposes only)
92
+ ├── types.py ← resolve_portal_type + IContentTypeSynonyms extension point
93
+ └── configure.zcml ← one <utility provides=IAIAssistantCapability name=...> per capability
94
+ ```
95
+
96
+ `AICapability` ([`capabilities/base.py`](capabilities/base.py)) sets `name`,
97
+ `description` (sent to the model), `parameters` (a JSON Schema for the call
98
+ args), and `permission` (a Zope permission). It exposes `allowed_for(context)`
99
+ (a real `checkPermission`), `tool_schema()` (the OpenAI function definition),
100
+ and `run(context, request, **kwargs)` returning a JSON-serializable result.
101
+
102
+ ### Security model — read this before adding a capability
103
+
104
+ - **The manifest is a filter; `run` is the enforcement.** `manifest_for(context)`
105
+ only offers the model the capabilities the current user is allowed to use, but
106
+ `AssistantTool._run_capability` **re-checks `allowed_for` before every call**
107
+ and returns an error payload (not a raise) if it fails. A hallucinated or
108
+ malicious tool call can never do something the user can't.
109
+ - **No writes inside the loop.** A capability that would change content must
110
+ only *propose* it. `propose_draft` validates + generates but creates nothing;
111
+ the proposal bubbles up (`result["proposal"]`) to the widget as a confirm
112
+ card. The actual write happens in a separate, explicit, permission-gated
113
+ endpoint (`@aitools-assistant-apply`) that **re-validates server-side** — the
114
+ client's proposal is untrusted, so tampering can't escalate.
115
+
116
+ ### Content-type synonyms (extensible)
117
+
118
+ `resolve_portal_type(value)` ([`capabilities/types.py`](capabilities/types.py))
119
+ lets the model/editor use loose type names. Precedence: exact id →
120
+ case-insensitive id → type title → synonyms; it only ever returns a type that
121
+ exists on the site. Synonyms come from the built-in `TYPE_SYNONYMS` **plus**
122
+ every registered [`IContentTypeSynonyms`](interfaces.py) utility, merged by
123
+ `collect_synonyms()` (add-on entries override built-ins on conflict).
124
+ Add-ons that ship content types register their own provider (subclass
125
+ `SynonymProvider`, set `synonyms`, register a `<utility>`) to teach the
126
+ assistant friendly names — no change to this package.
127
+
128
+ ### Adding a new capability — checklist
129
+
130
+ 1. Create `capabilities/<name>.py` subclassing `AICapability`; set `name`,
131
+ `description`, `parameters` (JSON Schema), `permission`; implement `run`.
132
+ 2. Register it in [`capabilities/configure.zcml`](capabilities/configure.zcml)
133
+ as `<utility ... provides="collective.aitools.interfaces.IAIAssistantCapability"
134
+ name="<name>" />`.
135
+ 3. Read-only? Safe to run in the loop. Does it write? Return a `proposal`
136
+ instead and add a handler to `AssistantApply` (`services/assistant.py`) that
137
+ re-validates and performs the write.
138
+ 4. Add tests; update this file.
139
+
140
+ ### REST endpoints
141
+
142
+ - `@aitools-assistant` (POST, permission `zope2.View`) — drives the `assistant`
143
+ tool over the client's `{messages}` history; returns `{reply, steps,
144
+ proposals}`. History is sanitized/clamped server-side; the system prompt and
145
+ tool turns are added by the tool, never taken from the client.
146
+ - `@aitools-assistant-apply` (POST, permission `cmf.ModifyPortalContent`) —
147
+ applies a user-confirmed `{proposal}`. Implements `create_draft` (re-validates
148
+ via `propose_draft.validate_draft`, creates a private draft, sets the body
149
+ only on types that have a `text` RichText field). Add more proposal types
150
+ here.
151
+
152
+ > The chat endpoint is gated at `View` (so the authenticated members the viewlet
153
+ > targets can chat; capabilities self-gate). Anonymous rate-limiting is **not**
154
+ > done yet — see [What's deferred](#whats-deferred).
155
+
156
+ ### Classic-UI chat widget
157
+
158
+ A floating chat widget, separate from the edit-form tools:
159
+
160
+ - **Viewlet** ([`browser/assistant.py`](browser/assistant.py)) — renders the
161
+ trigger in `IPortalFooter` when the tool is enabled and the user is
162
+ **authenticated** (`not api.user.is_anonymous()` — deliberately *not* a
163
+ permission check, since the viewlet runs on the root where editor perms may be
164
+ absent). Emits config incl. both endpoints, translated labels, and a
165
+ `plone.protect` CSRF `token` (`createToken()`).
166
+ - **Pattern** ([`resources/js/assistant.pattern.js`](../../../resources/js/assistant.pattern.js))
167
+ — a launcher + toggleable panel with chat bubbles, proposal confirm cards, and
168
+ a "Start new chat" (↻) button. The transcript and open/closed state persist in
169
+ **sessionStorage**, so navigating (a full reload) keeps the conversation and
170
+ re-opens the widget. Assistant replies render through the safe Markdown helper
171
+ [`resources/js/lib/markdown.js`](../../../resources/js/lib/markdown.js)
172
+ (HTML-escaped first, then links/emphasis; link hrefs scheme-checked).
173
+ - **CSRF** — writes (the apply request) require the token; the pattern sends it
174
+ via the `X-CSRF-TOKEN` header (`postJSON`'s `extraHeaders` arg). `postJSON`
175
+ lives in [`lib/ui.js`](../../../resources/js/lib/ui.js).
176
+
177
+ ## The AI Tools control panel
178
+
179
+ [`controlpanels/ai_tools.py`](controlpanels/ai_tools.py) mirrors the
180
+ `collective.aisettings` "AI Settings" panel exactly: a `RegistryEditForm`
181
+ (`IAIToolsSettings`) wrapped with `layout.wrap_form` for classic Plone, plus a
182
+ `RegistryConfigletPanel` adapter named `ai-tools` for the Volto REST surface
183
+ (`@controlpanels/ai-tools`). The Volto widget itself is not built yet — that's
184
+ the deferred frontend work.
185
+
186
+ ## Classic-UI edit-form integration (editor tools)
187
+
188
+ Editor-facing UI is built as **Patternslib patterns** bundled with webpack (the
189
+ same setup other ESP packages use, e.g. `collective.alerts`). Each tool is a
190
+ REST endpoint + a viewlet that emits a Patternslib *trigger element* + a pattern
191
+ in the bundle. There are four edit-form tools:
192
+
193
+ | Tool | Endpoint | Button fills |
194
+ | --- | --- | --- |
195
+ | Help me write | `@aitools-help-me-write` | TinyMCE body (draft / rewrite) |
196
+ | Get image details | `@aitools-recognize-image` | Title / Description / Tags from an image |
197
+ | Summarize | `@aitools-summarize` | Description from the body text |
198
+ | Suggest tags | `@aitools-suggest-tags` | Tags from the body text (vocabulary-aware) |
199
+
200
+ - **REST endpoints** — `services/*.py`, all `for="*"`, permission
201
+ `cmf.ModifyPortalContent`; each just drives its tool and returns `{result}` /
202
+ `{error}`. Model choice / prompts / the enabled gate stay server-side. The
203
+ text tools take the current form text in the body (`{text}`) since the content
204
+ may be unsaved; recognize-image takes the chosen `{image}` data-URI.
205
+ - **Viewlets** — `browser/*.py` render (in `IPortalFooter`, only on edit/add
206
+ forms and only when the tool is enabled) a hidden `<div class="pat-aitools-…">`
207
+ whose `data-pat-aitools-…` attribute carries the JSON config (endpoint,
208
+ translated labels, …). No script/CSS is injected by the viewlet — the bundle
209
+ is loaded globally by the resource registry.
210
+ - **Patterns** (bundle source under [`resources/`](../../../resources), *not*
211
+ `browser/static`): `help-me-write.pattern.js` hooks the global `window.tinymce`
212
+ registry; `recognize-image.pattern.js` attaches a button per image file field;
213
+ `summarize.pattern.js` / `suggest-tags.pattern.js` are built from the shared
214
+ `lib/content-tool.js` factory (read body text via `lib/fields.getBodyText` →
215
+ POST → fill one empty field). Shared field/widget logic (locating widgets,
216
+ fill-if-empty, the select2 tags API) lives in `lib/fields.js`; the button+status
217
+ UI in `lib/ui.js`. All tools fill target fields **only when empty**. SCSS is
218
+ imported by the patterns.
219
+
220
+ ### Shared backend helpers
221
+
222
+ - `tools/text.py` — `get_body_text(context)` (title + description + every
223
+ RichText field, HTML-stripped, capped) and `clean_tags()` (trim / de-dupe /
224
+ clamp), reused by the tools and the CLI scripts.
225
+ - `backfill.py` — `setup_admin` / `resolve_site` / `run_backfill(site,
226
+ process_item, …)`: the catalog-walk + tally + periodic-commit loop behind every
227
+ `scripts/*.py` backfill (recognize-images, summarize-content, suggest-tags).
228
+
229
+ ### Building the bundle
230
+
231
+ Source is ES6 in `backend/resources/`; webpack (via `@patternslib/dev`) compiles
232
+ it to `browser/static/aitools-remote.min.js` (module-federation remote entry).
233
+ That output **is committed** and ships in the wheel.
234
+
235
+ ```sh
236
+ make install-npm # npm install (first time)
237
+ make build-resources # npm run build → browser/static/aitools-*.min.js
238
+ make watch-resources # rebuild on change
239
+ make check-resources # CI: build + fail if the committed bundle is stale
240
+ ```
241
+
242
+ Wiring: `resources/index.js` → `resources/aitools-config.js` imports the
243
+ patterns and calls `registry.init()`. The bundle is registered as
244
+ `plone.bundles/aitools` in
245
+ [`profiles/default/registry/resources.xml`](profiles/default/registry/resources.xml)
246
+ pointing at `++plone++collective.aitools/aitools-remote.min.js`; the
247
+ `++plone++collective.aitools` static directory is declared with `<plone:static>`
248
+ in [`browser/configure.zcml`](browser/configure.zcml).
249
+
250
+ When you add another edit-form tool, follow the same endpoint + viewlet-trigger
251
+ + pattern trio. The TinyMCE selection/replacement helpers and the field-fill
252
+ helpers in the patterns are the reusable parts.
253
+
254
+ Registry records use **no prefix** (keyed
255
+ `collective.aitools.interfaces.IAIToolsSettings.<field>`), matching aisettings.
256
+
257
+ ## collective.aisettings coupling
258
+
259
+ - [`dependencies.zcml`](dependencies.zcml) `<include>`s `collective.aisettings`
260
+ so its `IAIService` utility is registered (it also auto-includes in a real
261
+ site via `plone.autoinclude`; the explicit include is what makes it load in
262
+ the test layer).
263
+ - [`profiles/default/metadata.xml`](profiles/default/metadata.xml) depends on
264
+ `profile-collective.aisettings:default` so installing this add-on installs
265
+ aisettings too.
266
+ - Both this package's and aisettings' `setuphandlers/initial.py` import
267
+ `plone.exportimport` (Plone 6.1 core, for example-content import). The dev
268
+ venv must have it; if a fresh `make install` lacks it, install
269
+ `plone.exportimport` (matching aisettings' pin, 2.0.1).
270
+
271
+ ## What's deferred
272
+
273
+ - **Embeddings tool** — intentionally not implemented yet (vector
274
+ storage/proximity-search backend undecided; `collective.vectorsearch`
275
+ exists but computes embeddings locally via FastEmbed rather than through
276
+ aisettings). Once available, `search_site` should rank semantically.
277
+ - **Volto counterparts** of these tools, the assistant widget, and the
278
+ control-panel widget.
279
+ - **Assistant hardening** — anonymous access + rate-limiting/abuse controls for
280
+ the chat endpoint (today it's authenticated-only by viewlet, `View` by
281
+ endpoint); streaming replies; more `apply` proposal types beyond
282
+ `create_draft`; showing `steps` in the widget.
283
+
284
+ ## Tests
285
+
286
+ `backend/tests/tools/test_tools.py` and
287
+ `backend/tests/controlpanels/test_ai_tools.py`. The `fake_service` fixture
288
+ swaps the global `IAIService` for a recorder so `run` can be asserted without
289
+ a network/model. Run with `make test` from `backend/`.
@@ -0,0 +1,14 @@
1
+ """Init and utils."""
2
+
3
+ from zope.i18nmessageid import MessageFactory
4
+
5
+ import logging
6
+
7
+
8
+ __version__ = "1.0.0"
9
+
10
+ PACKAGE_NAME = "collective.aitools"
11
+
12
+ _ = MessageFactory(PACKAGE_NAME)
13
+
14
+ logger = logging.getLogger(PACKAGE_NAME)
@@ -0,0 +1,110 @@
1
+ """Shared catalog-walk helper for the CLI backfill scripts.
2
+
3
+ The ``scripts/*.py`` entry points (run via ``zconsole run``) bind the admin
4
+ user, resolve the Plone site and then hand a per-item callable to
5
+ :func:`run_backfill`, which walks the catalog subtree, tallies outcomes and
6
+ commits periodically. Keeping the walk here (rather than in each script) means
7
+ the scripts stay tiny and the logic is import-testable.
8
+ """
9
+
10
+ from AccessControl.SecurityManagement import newSecurityManager
11
+
12
+ import transaction
13
+
14
+
15
+ def setup_admin(app):
16
+ """Bind the Zope ``admin`` user as the acting user. Returns the user."""
17
+ admin = app.acl_users.getUserById("admin")
18
+ if admin is None:
19
+ raise SystemExit("No 'admin' user found; cannot run.")
20
+ newSecurityManager(None, admin.__of__(app.acl_users))
21
+ return admin
22
+
23
+
24
+ def resolve_site(app, site_id):
25
+ """Return the Plone site object, setting it as the active local site."""
26
+ from zope.component.hooks import setSite
27
+
28
+ site = app.get(site_id)
29
+ if site is None:
30
+ raise SystemExit(f"Plone site {site_id!r} not found under the app root.")
31
+ setSite(site)
32
+ return site
33
+
34
+
35
+ def resolve_path(site, path):
36
+ """Turn a user-supplied ``--path`` into an absolute catalog path.
37
+
38
+ Accepts an absolute path ("/Plone/news") or one relative to the site
39
+ ("news"); an empty path means the whole site.
40
+ """
41
+ site_path = "/".join(site.getPhysicalPath())
42
+ if not path:
43
+ return site_path
44
+ if not path.startswith("/"):
45
+ path = "/" + path
46
+ if path == site_path or path.startswith(site_path + "/"):
47
+ return path
48
+ return site_path + path
49
+
50
+
51
+ def run_backfill(
52
+ site,
53
+ process_item,
54
+ *,
55
+ path="",
56
+ dry_run=False,
57
+ commit_every=10,
58
+ limit=0,
59
+ ):
60
+ """Walk the catalog subtree and apply ``process_item`` to each object.
61
+
62
+ ``process_item(obj)`` must return one of ``"updated"``, ``"skipped"`` or
63
+ ``"failed"`` and is responsible for its own field logic (it should not
64
+ commit). Returns a tally dict with keys ``processed``, ``updated``,
65
+ ``skipped`` and ``failed``.
66
+ """
67
+ catalog = site.portal_catalog
68
+ target_path = resolve_path(site, path)
69
+ print(f"Scanning {target_path!r} ...")
70
+
71
+ tally = {"processed": 0, "updated": 0, "skipped": 0, "failed": 0}
72
+ for brain in catalog(path={"query": target_path}):
73
+ try:
74
+ obj = brain.getObject()
75
+ except Exception as exc:
76
+ print(f" ! {brain.getPath()}: could not load ({exc})")
77
+ tally["failed"] += 1
78
+ continue
79
+
80
+ try:
81
+ outcome = process_item(obj)
82
+ except Exception as exc:
83
+ print(f" ! {obj.absolute_url()}: failed ({exc})")
84
+ tally["failed"] += 1
85
+ continue
86
+
87
+ if outcome is None:
88
+ continue # not applicable to this item; don't count it
89
+
90
+ tally["processed"] += 1
91
+ tally[outcome] += 1
92
+ due = commit_every and tally["updated"] % commit_every == 0
93
+ if outcome == "updated" and not dry_run and due:
94
+ transaction.commit()
95
+
96
+ if limit and tally["processed"] >= limit:
97
+ print(f"Reached --limit {limit}; stopping.")
98
+ break
99
+
100
+ if dry_run:
101
+ transaction.abort()
102
+ else:
103
+ transaction.commit()
104
+
105
+ print(
106
+ "Done. processed={processed} updated={updated} "
107
+ "skipped={skipped} failed={failed}".format(**tally)
108
+ + (" (dry-run, nothing written)" if dry_run else "")
109
+ )
110
+ return tally
File without changes
@@ -0,0 +1,66 @@
1
+ """Viewlet that mounts the classic-UI chat "Assistant" widget.
2
+
3
+ Renders (site-wide, in ``IPortalFooter``) the Patternslib trigger for the chat
4
+ widget when the ``assistant`` tool is enabled and the visitor is *authenticated*
5
+ (anonymous visitors don't get the widget in this first, editor-focused slice).
6
+ We deliberately gate on "logged in or not" rather than a permission, because
7
+ the viewlet is rendered on the portal root where an editor permission may not
8
+ be granted; the individual capabilities enforce their own permissions.
9
+ """
10
+
11
+ from collective.aitools import _
12
+ from collective.aitools.tools import get_tool
13
+ from plone import api
14
+ from plone.app.layout.viewlets.common import ViewletBase
15
+ from plone.protect.authenticator import createToken
16
+ from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
17
+ from zope.i18n import translate
18
+
19
+ import json
20
+
21
+
22
+ class AssistantViewlet(ViewletBase):
23
+ index = ViewPageTemplateFile("templates/assistant_viewlet.pt")
24
+
25
+ def available(self) -> bool:
26
+ tool = get_tool("assistant")
27
+ if tool is None or not tool.is_enabled():
28
+ return False
29
+ # Show only to authenticated users (not anonymous). No permission check:
30
+ # the viewlet renders on the root, where editor perms may be absent.
31
+ return not api.user.is_anonymous()
32
+
33
+ def render(self):
34
+ if not self.available():
35
+ return ""
36
+ return self.index()
37
+
38
+ def endpoint_url(self) -> str:
39
+ return f"{self.context.absolute_url()}/@aitools-assistant"
40
+
41
+ def apply_url(self) -> str:
42
+ return f"{self.context.absolute_url()}/@aitools-assistant-apply"
43
+
44
+ def config_json(self) -> str:
45
+ t = lambda msg: translate(msg, context=self.request)
46
+ return json.dumps({
47
+ "endpoint": self.endpoint_url(),
48
+ "applyEndpoint": self.apply_url(),
49
+ # CSRF token for the write (apply) request; plone.protect accepts
50
+ # it via the X-CSRF-TOKEN header. Freshly minted per page render.
51
+ "token": createToken(),
52
+ "labels": {
53
+ "title": t(_("Assistant")),
54
+ "open": t(_("Open the assistant")),
55
+ "close": t(_("Close")),
56
+ "new_chat": t(_("Start new chat")),
57
+ "placeholder": t(_("Ask me anything about this site…")),
58
+ "send": t(_("Send")),
59
+ "loading": t(_("Thinking…")),
60
+ "error": t(_("Something went wrong. Please try again.")),
61
+ "create": t(_("Create draft")),
62
+ "creating": t(_("Creating…")),
63
+ "created": t(_("Draft created:")),
64
+ "open_item": t(_("Open")),
65
+ },
66
+ })
@@ -0,0 +1,57 @@
1
+ <configure
2
+ xmlns="http://namespaces.zope.org/zope"
3
+ xmlns:browser="http://namespaces.zope.org/browser"
4
+ xmlns:plone="http://namespaces.plone.org/plone"
5
+ i18n_domain="collective.aitools"
6
+ >
7
+
8
+ <!-- Webpack build output (Patternslib bundle) served under
9
+ ++plone++collective.aitools, registered as a Plone bundle in
10
+ profiles/default/registry/resources.xml. -->
11
+ <plone:static
12
+ directory="static"
13
+ name="collective.aitools"
14
+ type="plone"
15
+ />
16
+
17
+ <browser:viewlet
18
+ name="collective.aitools.help_me_write"
19
+ manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
20
+ class=".help_me_write.HelpMeWriteViewlet"
21
+ permission="zope2.View"
22
+ layer="..interfaces.IBrowserLayer"
23
+ />
24
+
25
+ <browser:viewlet
26
+ name="collective.aitools.recognize_image"
27
+ manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
28
+ class=".recognize_image.RecognizeImageViewlet"
29
+ permission="zope2.View"
30
+ layer="..interfaces.IBrowserLayer"
31
+ />
32
+
33
+ <browser:viewlet
34
+ name="collective.aitools.summarize"
35
+ manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
36
+ class=".summarize.SummarizeViewlet"
37
+ permission="zope2.View"
38
+ layer="..interfaces.IBrowserLayer"
39
+ />
40
+
41
+ <browser:viewlet
42
+ name="collective.aitools.suggest_tags"
43
+ manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
44
+ class=".suggest_tags.SuggestTagsViewlet"
45
+ permission="zope2.View"
46
+ layer="..interfaces.IBrowserLayer"
47
+ />
48
+
49
+ <browser:viewlet
50
+ name="collective.aitools.assistant"
51
+ manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
52
+ class=".assistant.AssistantViewlet"
53
+ permission="zope2.View"
54
+ layer="..interfaces.IBrowserLayer"
55
+ />
56
+
57
+ </configure>
@@ -0,0 +1,70 @@
1
+ """Viewlet that emits the classic-UI "Help me write" Patternslib trigger.
2
+
3
+ Only renders when the ``help_me_write`` tool is enabled and the current view
4
+ is an edit or add form. It emits a hidden element carrying a small JSON config
5
+ (endpoint URL, the operations with their labels and whether they need typed
6
+ input, and the translated UI strings) in its ``data-pat-aitools-help-me-write``
7
+ attribute. The ``aitools`` bundle (built from ``resources/``) picks that up and
8
+ builds the TinyMCE button. The bundle itself is loaded globally through the
9
+ resource registry, so the viewlet no longer injects any script/CSS.
10
+ """
11
+
12
+ from collective.aitools import _
13
+ from collective.aitools.tools import get_tool
14
+ from collective.aitools.tools.help_me_write import OPERATION_TITLES
15
+ from collective.aitools.tools.help_me_write import OPERATIONS_REQUIRING_INPUT
16
+ from plone.app.layout.viewlets.common import ViewletBase
17
+ from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
18
+ from zope.i18n import translate
19
+
20
+ import json
21
+
22
+
23
+ class HelpMeWriteViewlet(ViewletBase):
24
+ index = ViewPageTemplateFile("templates/help_me_write_viewlet.pt")
25
+
26
+ def _is_form_view(self) -> bool:
27
+ url = (self.request.get("ACTUAL_URL", "") or self.request.getURL()).rstrip("/")
28
+ last = url.rsplit("/", 1)[-1]
29
+ return last in ("edit",) or "++add++" in url
30
+
31
+ def available(self) -> bool:
32
+ tool = get_tool("help_me_write")
33
+ if tool is None or not tool.is_enabled():
34
+ return False
35
+ return self._is_form_view()
36
+
37
+ def render(self):
38
+ if not self.available():
39
+ return ""
40
+ return self.index()
41
+
42
+ # -- template helpers --
43
+
44
+ def endpoint_url(self) -> str:
45
+ return f"{self.context.absolute_url()}/@aitools-help-me-write"
46
+
47
+ def _operations(self):
48
+ return [
49
+ {
50
+ "id": op_id,
51
+ "label": translate(title, context=self.request),
52
+ "needsInput": op_id in OPERATIONS_REQUIRING_INPUT,
53
+ }
54
+ for op_id, title in OPERATION_TITLES.items()
55
+ ]
56
+
57
+ def config_json(self) -> str:
58
+ t = lambda msg: translate(msg, context=self.request)
59
+ return json.dumps({
60
+ "endpoint": self.endpoint_url(),
61
+ "operations": self._operations(),
62
+ "labels": {
63
+ "button": t(_("Help me write")),
64
+ "send": t(_("Send")),
65
+ "loading": t(_("Generating...")),
66
+ "error": t(_("Something went wrong. Please try again.")),
67
+ "noText": t(_("Select or enter some text first.")),
68
+ "draftPlaceholder": t(_("What do you want to write about?")),
69
+ },
70
+ })
@@ -0,0 +1,57 @@
1
+ """Viewlet that emits the classic-UI "Get image details" Patternslib trigger.
2
+
3
+ Only renders when the ``recognize_image`` tool is enabled and the current view
4
+ is an edit or add form. It emits a hidden element carrying a small JSON config
5
+ (endpoint URL and the translated UI strings) in its
6
+ ``data-pat-aitools-recognize-image`` attribute. The ``aitools`` bundle (built
7
+ from ``resources/``) picks that up and adds a "Get image details" button next
8
+ to each image file field. The bundle itself is loaded globally through the
9
+ resource registry, so the viewlet no longer injects any script/CSS.
10
+ """
11
+
12
+ from collective.aitools import _
13
+ from collective.aitools.tools import get_tool
14
+ from plone.app.layout.viewlets.common import ViewletBase
15
+ from Products.Five.browser.pagetemplatefile import ViewPageTemplateFile
16
+ from zope.i18n import translate
17
+
18
+ import json
19
+
20
+
21
+ class RecognizeImageViewlet(ViewletBase):
22
+ index = ViewPageTemplateFile("templates/recognize_image_viewlet.pt")
23
+
24
+ def _is_form_view(self) -> bool:
25
+ url = (self.request.get("ACTUAL_URL", "") or self.request.getURL()).rstrip("/")
26
+ last = url.rsplit("/", 1)[-1]
27
+ return last in ("edit",) or "++add++" in url
28
+
29
+ def available(self) -> bool:
30
+ tool = get_tool("recognize_image")
31
+ if tool is None or not tool.is_enabled():
32
+ return False
33
+ return self._is_form_view()
34
+
35
+ def render(self):
36
+ if not self.available():
37
+ return ""
38
+ return self.index()
39
+
40
+ # -- template helpers --
41
+
42
+ def endpoint_url(self) -> str:
43
+ return f"{self.context.absolute_url()}/@aitools-recognize-image"
44
+
45
+ def config_json(self) -> str:
46
+ t = lambda msg: translate(msg, context=self.request)
47
+ return json.dumps({
48
+ "endpoint": self.endpoint_url(),
49
+ "labels": {
50
+ "button": t(_("Get image details")),
51
+ "loading": t(_("Analyzing image...")),
52
+ "error": t(_("Something went wrong. Please try again.")),
53
+ "noImage": t(_("Choose an image first.")),
54
+ "done": t(_("Filled in the empty fields.")),
55
+ "readError": t(_("Could not read the selected image.")),
56
+ },
57
+ })