Compare commits

..
114 Commits
Author SHA1 Message Date
Sam Saffron 67bc4c038a FEATURE: bring back hidden settings
The hidden settings have general use and may be already set.

Helper one is particularly important cause it sets a default for a large
number of settings

Additionally hiding access means people can not remove LLMs without console
access
2025-07-09 16:06:27 +10:00
Keegan George 625442af3c FIX: title suggestions should return 5 unique titles (#1491)
This update fixes a regression from https://github.com/discourse/discourse-ai/pull/1484, which caused AI helper title suggestions to begin suggesting numerous non-unique titles because it was looping through structured responses incorrectly.
2025-07-08 06:30:09 -07:00
Natalie Tay 699ea3f501 DEV: Add backtrace to logs (#1489) 2025-07-08 10:39:43 +08:00
Natalie Tay 56f025cf44 FIX: Localize description excerpts as they have limits (#1490) 2025-07-08 10:36:41 +08:00
Rafael dos Santos Silva 7357280e88 FEATURE: Add old OpenAI tokenizer to embeddings (#1487) 2025-07-07 15:07:27 -03:00
Natalie Tay 6f8960e549 FIX: Pass topic to context (#1488) 2025-07-07 14:59:48 +08:00
Rafael dos Santos Silva 6247906c13 FEATURE: Seamless embedding model upgrades (#1486) 2025-07-04 16:44:03 -03:00
Sam ab5edae121 FIX: make AI helper more robust (#1484)
* FIX: make AI helper more robust

- If JSON is broken for structured output then lean on a more forgiving parser
- Gemini 2.5 flash does not support temp, support opting out
- Evals for assistant were broken, fix interface
- Add some missing LLMs
- Translator was not mapped correctly to the feature - fix that
- Don't mix XML in prompt for translator

* lint

* correct logic

* simplify code

* implement best effort json parsing direct in the structured output object
2025-07-04 14:47:11 +10:00
Joffrey JAFFEUX 0f904977a4 FIX: correctly translate automation name (#1485)
* FIX: correctly translation automation name

The key is persona, not name.

* simplify
2025-07-04 13:01:45 +10:00
Natalie Tay 2b9a4f9232 FIX: Ignore captions and quotes when detecting locale and update prompts (#1483)
A more deterministic way of making sure the LLM detects the correct language (instead of relying on prompt to LLM to ignore it) is to take the cooked and remove unwanted elements.

In this commit 
- we remove quotes, image captions, etc. and only take the remaining text, falling back to the unadulterated cooked
- and update prompts related to detection and translation
- /152465/12
2025-07-03 22:57:48 +08:00
moin-Jana 8b4f401a7b FIX: Typo in custom_tool_exists text (#1480) 2025-07-03 10:17:31 -03:00
Discourse Translator Bot 92e3615378 Update translations (#1479) 2025-07-02 22:36:46 +02:00
Rafael dos Santos SilvaandRoman Rizzi d792919ddf DEV: Move tokenizers to a gem (#1481)
Also renames the Mixtral tokenizer to Mistral.

See gem at github.com/discourse/discourse_ai-tokenizers


Co-authored-by: Roman Rizzi <[email protected]>
2025-07-02 14:43:03 -03:00
Roman Rizzi 75fb37144f FEATURE: Use personas for generating hypothetical posts (#1482)
* FEATURE: Use personas for generating hypothetica posts

* Update prompt
2025-07-02 10:56:38 -03:00
Sam 40fa527633 FIX: cross talk when in ai helper (#1478)
Previous to this change we reused channels for proofreading progress and
ai helper progress

The new changeset ensures each POST to stream progress gets a dedicated
message bus channel

This fixes a class of issues where the wrong information could be displayed
to end users on subsequent proofreading or helper calls

* fix tests

* fix implementation (got to subscribe at 0)
2025-07-01 18:02:16 +10:00
moin-Jana 897f31e564 UX: Fix typo in bot.description text (#1474)
Introducing a typo isn't the right way to bypass the check that blocks the term "private messages".

Nice try, though ;)

I changed it to "personal messages".
2025-07-01 17:24:05 +10:00
Yuriy Kurant 8527279594 dev: removes messages section from sidebar (#1477) 2025-07-01 17:23:11 +10:00
Kris 4ad64ed3b6 DEV: replace sortBy with toSorted (#1476) 2025-06-30 16:41:59 -04:00
Roman Rizzi 5ca7d5f256 FIX: Strip uploads from msg when searching for rag fragments (#1475) 2025-06-30 15:03:17 -03:00
Natalie Tay a94daa14e2 FIX: Return no topics when embeddings is disabled (#1473)
When an invalid model is set for embeddings, topics do not load even if embeddings is disabled.

Error:
## RuntimeError in TopicsController#show
Invalid embeddings selected model

This commit checks for valid settings before attempting to load related topics.
2025-06-30 17:45:04 +08:00
Kris 262bd8b145 UX: add filter to features page, update styles (#1471)
* UX: add filter to features page, update styles

* merge fix

* update toggle spec

* test fix
2025-06-30 09:26:53 +10:00
Roman Rizzi 57b00526f8 FIX: Clarify spam response expectations. (#1470) 2025-06-27 16:59:55 -03:00
Roman Rizzi 8d943fa29d FEATURE: Display spam module on features list. (#1469) 2025-06-27 14:18:01 -03:00
Roman Rizzi b35f9bcc7c FEATURE: Use Persona's when scanning posts for spam (#1465) 2025-06-27 10:35:47 -03:00
Sam cc4e9e030f FIX: normalize keys in structured output (#1468)
* FIX: normalize keys in structured output

Previously we did not validate the hash passed in to structured
outputs which could either be string based or symbol base

Specifically this broke structured outputs for Gemini in some
specific cases.

* comment out flake
2025-06-27 15:42:48 +10:00
Sam 73768ce920 FEATURE: Display bot in feature list (#1466)
- allows features to have multiple llms and multiple personas
- sorts module list
- adds Bot as a first class module
- fixes issue where search module was always configured
- some tests
2025-06-27 12:35:41 +10:00
Rafael dos Santos Silva a40e2d3156 FEATURE: Update OpenAI tokenizer to GPT-4o and later (#1467) 2025-06-26 15:26:09 -03:00
Kris 2fe99a0bec UX: add missing translation for uploads (#1464) 2025-06-25 11:36:00 -04:00
Sam 3e74f09d06 FEATURE: improve custom tool infra (#1463)
- Add support for `chain.streamCustomRaw(test)` that can be used to stream text from a JS tool direct to composer
- Add support for llm params in `llm.generate` which unlocks stuff like structured outputs
- Add discourse.createStagedUser, discourse.createTopic  and discourse.createPost - for content creation
2025-06-25 16:25:44 +10:00
Discourse Translator Bot 3cfc749fad Update translations (#1462) 2025-06-24 16:29:23 +02:00
Jarek RadoszandRoman Rizzi 5735f063a3 FIX: A typo in bot filtration in ai-bot-header-icon (#1455)
* FIX: A typo in bot filtration in ai-bot-header-icon

* FIX: Show header icon when there's only one persona with a default LLM set

---------

Co-authored-by: Roman Rizzi <[email protected]>
2025-06-24 10:51:07 -03:00
Kris 757c93e514 UX: make topic list gists link to the topic (#1459) 2025-06-24 09:14:18 -04:00
Natalie Tay 4c1cd5d819 UX: Align llm button in ai features (#1461) 2025-06-24 17:23:58 +08:00
Sam 471f96f972 FEATURE: allow seeing configured LLM on feature page (#1460)
This is an interim fix so we can at least tell what feature is
being used for what LLM.

It also adds some test coverage to the feature page.
2025-06-24 17:42:47 +10:00
dependabot[bot] 1f851bb2e1 bump rack from 3.1.14 to 3.1.16 (#1408)
---
updated-dependencies:
- dependency-name: rack
  dependency-version: 3.1.16
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 12:42:22 +10:00
Sam 9f2a4094f5 FEATURE: persona/tool import and export (#1450)
Introduces import/export feature for tools and personas.

Uploads are omitted for now, and will be added in a future PR 

*   **Backend:**
    *   Adds `import` and `export` actions to `Admin::AiPersonasController` and `Admin::AiToolsController`.
    *   Introduces `DiscourseAi::PersonaExporter` and `DiscourseAi::PersonaImporter` services to manage JSON serialization and deserialization.
    *   The export format for a persona embeds its associated custom tools. To ensure portability, `AiTool` references are serialized using their `tool_name` rather than their internal database `id`.
    *   The import logic detects conflicts by name. A `force=true` parameter can be passed to overwrite existing records.

*   **Frontend:**
    *   `AiPersonaListEditor` and `AiToolListEditor` components now include an "Import" button that handles file selection and POSTs the JSON data to the respective `import` endpoint.
    *   `AiPersonaEditorForm` and `AiToolEditorForm` components feature an "Export" button that triggers a download of the serialized record.
    *   Handles import conflicts (HTTP `409` for tools, `422` for personas) by showing a `dialog.confirm` prompt to allow the user to force an overwrite.

*   **Testing:**
    *   Adds comprehensive request specs for the new controller actions (`#import`, `#export`).
    *   Includes unit specs for the `PersonaExporter` and `PersonaImporter` services.
* Persona import and export implemented
2025-06-24 12:41:10 +10:00
Roman Rizzi eea96d6df9 FIX: Include JSON instructions in Helper default personas (#1458) 2025-06-23 11:57:50 -03:00
Natalie Tay 683bb5725b DEV: Split content based on llmmodel's max_output_tokens (#1456)
In discourse/discourse-translator#249 we introduced splitting content (post.raw) prior to sending to translation as we were using a sync api.

Now that we're streaming thanks to #1424, we'll chunk based on the LlmModel.max_output_tokens.
2025-06-23 21:11:20 +08:00
Natalie Tay 740be26625 DEV: Also make sure locale detection skips PMs that are not group PMs when public content only (#1457)
In the earlier PR https://github.com/discourse/discourse-ai/pull/1432, when `SiteSetting.ai_translation_backfill_limit_to_public_content = false`, we **translate** PMs but **skip translating** PMs that do not involve groups.

This commit covers the missing case on **locale detection**.
2025-06-23 19:07:40 +08:00
Natalie Tay e2d7ca0bb9 DEV: Indicate backfill rate for translations is hourly (#1451)
* DEV: Indicate backfill rate for translations is hourly

* add ai_translation_max_post_length

* default value update
2025-06-21 15:45:09 +08:00
Keegan George 238538c405 DEV: Remove deprecated integer duration in toasts (#1453)
This update replaces deprecated integer duration with standardized duration of either `short` or `long` throughout the plugin usages of `FloatKit` toasts.
2025-06-20 12:42:08 -07:00
Keegan George a4194d3fb2 FIX: AI preferences tab button not appearing unless Helper enabled (#1452)
This update fixes an issue where the AI user preferences tab was not appearing unless `SiteSetting.ai_helper_enabled` was `true`. This is because we previously checked for it's presence when user preferences only had a single setting related to Helper. However, since then, we've also added search discoveries setting there too. As such, we don't want it to depend on Helper. We also sneak in this update a modernization of converting the preferences template from `.hbs` to `.gjs`.
2025-06-20 10:12:08 -07:00
Sam eab6dd3f8e DEV: re-implement bulk sentiment classifier (#1449)
New implementation uses core concurrent job queue, it is more
robust and predictable than the one shipped in Concurrent.

Additionally:

- Trickles through updates during bulk classification
- Reports errors if we fail during a bulk classification

* push concurrency down to 40. 100 feels quite high.
2025-06-20 16:06:03 +10:00
Keegan GeorgeandJoffrey JAFFEUX baaa3d199a FIX: streaming related specs (#1448)
## 🔍 Overview
This update fixes an issue where message bus streaming related specs
were not working correctly. To do so we pass the `last_id` when
subscribing to `MessageBus` which allows us to unskip those broken
tests.

---------

Co-authored-by: Joffrey JAFFEUX <[email protected]>
2025-06-19 07:41:18 -07:00
Joffrey JAFFEUXandKeegan George 6a33e5154d DEV: makes ai menu helper a standalone menu (#1434)
The current menu was rendering inside the post text toolbar (on desktop). This is not ideal as the post text toolbar rendering is conditioned on the presence of text selection, when you click a button on the toolbar, by design of the web browsers you will lose your text selection, making all of this super tricky.

This commit makes desktop and mobile behave in the same way by rendering their own menu and capturing the quote state when we render the post text selection toolbar, this allows us to reason a much simpler way about the AI helper.

This commit also removes what appears to be an unused file and corrects which was seemingly copy/paste mistakes.

⚠️ Technical note, this commit is correcting the message bus subscription which amongst other things allows to write specs which are not flaky. However due to the current implementation we have a channel per post, which means we need to serialize on last message bus id per post. 

We have two possible solutions here:
- subscribe at the topic level
- refactor the code to be able to use `MessageBus.last_ids` to be able to grab multiple posts at once instead of having to call `MessageBus.last_id` and done one Redis call per post

---------

Co-authored-by: Keegan George <[email protected]>
2025-06-19 11:56:00 +02:00
Sam 37dbd48513 FIX: implement max_output tokens (anthropic/openai/bedrock/gemini/open router) (#1447)
* FIX: implement max_output tokens (anthropic/openai/bedrock/gemini/open router)

Previously this feature existed but was not implemented
Also updates a bunch of models to in our preset to point to latest

* implementing in base is safer, simpler and easier to manage

* anthropic 3.5 is getting older, lets use 4.0 here and fix spec
2025-06-19 16:00:11 +10:00
Natalie Tay 3e87e92631 DEV: Remove 'experimental' from translation features (#1439)
* DEV: Remove 'experimental' from translation features

* include compat

* include compat
2025-06-19 12:23:56 +08:00
Mark VanLandingham cd14b0c0be FIX: Bring back empty state message when appropriate (#1446)
The Today section was added always, but a side-effect was that we hid the empty state component. This commit brings back the empty state
2025-06-18 17:34:08 -05:00
Keegan George cea8fd423e FIX: unable to scroll AI bot persona selector (#1445)
This update fixes a UX issue on the AI bot conversations page where the persona selector dropdown doesn't scroll when there are many items and the viewport is small.

No tests as it's tricky to test scrolling.
2025-06-18 12:20:50 -07:00
Natalie Tay d7a2af5505 DEV: Prevent multiple translation per post (#1443)
We're seeing an aggressive number of translations being enqueued for a single post and locale. Historically, we trigger translation on `cooked` not `raw`, but that has changed a while back.

```
# from AiApiAuditLog, the same post is getting translated to the same locale within a few secs of each other
zh_CN - 2025-06-17 13:02:31 UTC
zh_CN - 2025-06-17 13:02:34 UTC
zh_CN - 2025-06-17 13:02:35 UTC
zh_CN - 2025-06-17 13:02:36 UTC
zh_CN - 2025-06-17 13:02:38 UTC
zh_CN - 2025-06-17 13:02:39 UTC
zh_CN - 2025-06-17 13:02:40 UTC
zh_CN - 2025-06-17 13:02:40 UTC
zh_CN - 2025-06-17 13:02:43 UTC
zh_CN - 2025-06-17 13:02:44 UTC
```

This PR prevents this from happening.
2025-06-18 13:24:02 +08:00
Discourse Translator Bot 6dbe19a772 Update translations (#1441) 2025-06-17 23:07:50 +02:00
Keegan George 62d746662a FIX: Cleanup properties on closing DiffModal (#1442)
This update ensures that we reset the tracked properties when closing the DiffModal so that the state doesn't leak when triggering the AI suggestions again. We also reset before suggesting new changes, thus if regeneration is called there shouldn't be any leaks either.

No tests in this PR as tests currently not working great due to streaming/animation issues. Will do a broader PR following up with various specs to improve test coverage here.
2025-06-17 13:57:46 -07:00
Rafael dos Santos Silva 9dccc1eb93 FEATURE: Add Qwen3 tokenizer and update Gemma to version 3 (#1440) 2025-06-17 10:25:03 -03:00
Natalie Tay df925f8304 DEV: Move examples out of prompt (#1438)
* DEV: Move examples out of prompt
2025-06-17 16:12:52 +08:00
Sam 32dc45ba4f FIX: never block spam scanning user (#1437)
Previously staff and bots would get scanned if TL was low
Additionally if somehow spam scanner user was blocked
(deactivated, silenced, banned) it would stop the feature from working

This adds an override that ensures unconditionally the user is setup correctly prior to scanning
2025-06-17 14:51:27 +10:00
Rafael dos Santos Silva bc8e57d7e8 DEV: Move title suggestion to an array (#1435) 2025-06-16 18:06:54 -03:00
Kris 24416c5b87 UX: focus conversation input on route transition and button click (#1404) 2025-06-13 17:45:51 -04:00
Natalie Tay b5e8277083 DEV: Move AI translation feature into an AI Feature (#1424)
This PR moves translations into an AI Feature

See https://github.com/discourse/discourse-ai/pull/1424 for screenshots
2025-06-13 10:17:27 +08:00
Keegan George 9be1049de6 DEV: Log AI related configuration to staff action log (#1416)
is update adds logging for changes made in the AI admin panel. When making configuration changes to Embeddings, LLMs, Personas, Tools, or Spam that aren't site setting related, changes will now be logged in Admin > Logs & Screening. This will help admins debug issues related to AI. In this update a helper lib is created called `AiStaffActionLogger` which can be easily used in the future to add logging support for any other admin config we need logged for AI.
2025-06-12 12:39:58 -07:00
Natalie Tay fc83bed7cd FIX: When allowing private content translation, only translate group PMs and not personal PMs (#1432)
We want to avoid translating PMs that are not group PMs. This condition is applied when `SiteSetting.ai_translation_backfill_limit_to_public_content = false`
2025-06-13 00:55:52 +08:00
Roman Rizzi 9b7f1e6ee9 FIX: Helper wasn't working when the persona doesn't use structured output (#1433) 2025-06-12 12:33:12 -03:00
Kris 22da440130 UX: add features to persona list and other style updates (#1405) 2025-06-12 08:23:10 -04:00
Sam 02bc9f645e FEATURE: hybrid artifact security mode (#1431)
In hybrid mode ai artifacts can optionally automatically run.

This is useful for cases where you may want to embed a survey and so on.

Additionally, artifacts now allow for better fidelity around display:

<div class="ai-artifact" data-ai-artifact-id="501" data-ai-artifact-height="300px" data-ai-artifact-autorun data-ai-artifact-seamless></div>

User can supply height and seamless mode to be seamlessly rendered with no box shadow and show full screen button.
2025-06-12 20:04:48 +10:00
Joffrey JAFFEUX b5a2ee31ab DEV: adds pointerup event to our select text helper (#1429)
This is needed for https://github.com/discourse/discourse/pull/33143

Ideally we should move all these helpers to core, or improve the ones in core.
2025-06-12 10:33:14 +02:00
SamandAlan Guo Xiang Tan ed311de937 FIX: various bugs in AI interface (#1430)
* FIX: improve transition logic in forms

previously back button would take you back to the /new route

* FIX: enum selection not working for persona tools

* seed information correctly in the DB

* fix broken spec

* Update assets/javascripts/discourse/components/ai-tool-editor-form.gjs

Co-authored-by: Alan Guo Xiang Tan <[email protected]>

---------

Co-authored-by: Alan Guo Xiang Tan <[email protected]>
2025-06-12 13:50:52 +10:00
Roman Rizzi 8c8fd969ef FIX: Don't check for #blank? when manipulating chunks (#1428) 2025-06-11 20:38:58 -03:00
Joffrey JAFFEUX 26217e51f9 DEV: a real selection change has a pointerup event (#1427)
This is needed for https://github.com/discourse/discourse/pull/33143 as we now rely on this pointerup event.
2025-06-12 00:59:21 +02:00
Keegan George 442530a154 UX: Show error and ability to try again when no suggestions (#1426)
## 🔍  Overview
When the title suggestions return no suggestions, there is no indication in the UI. In the tag suggester we show a toast when there aren't any suggestions but the request was a success. In this update we make a similar UI indication with a toast for both category and title suggestions. Additionally, for all suggestions we add a "Try again" button to the toast so that suggestions can be generated again if the results yield nothing the first time.
2025-06-11 12:05:10 -07:00
Sam a907bc891a FIX: improve admin api for artifact key values (#1425)
Previously we had a logic error and were showing admins keys
that are not theirs when querying for all keys

This makes the API cleaner, to get all results you need to be explicit always
2025-06-11 19:33:34 +10:00
Sam d97307e99b FEATURE: optionally support OpenAI responses API (#1423)
OpenAI ship a new API for completions called "Responses API"

Certain models (o3-pro) require this API.
Additionally certain features are only made available to the new API.

This allow enabling it per LLM.

see: https://platform.openai.com/docs/api-reference/responses
2025-06-11 17:12:25 +10:00
Natalie Tay 35d62a659b FIX: Skip edits if localization exists (#1422)
We will fine tune updating an outdated localization in the future. For now we are seeing that quick edits are happening and we need to prevent the job from being too trigger-happy.
2025-06-11 11:00:22 +08:00
Discourse Translator Bot f99309d1e1 Update translations (#1419) 2025-06-10 23:27:57 +02:00
Sam fdf0ff8a25 FEATURE: persistent key-value storage for AI Artifacts (#1417)
Introduces a persistent, user-scoped key-value storage system for
AI Artifacts, enabling them to be stateful and interactive. This
transforms artifacts from static content into mini-applications that can
save user input, preferences, and other data.

The core components of this feature are:

1.  **Model and API**:
    - A new `AiArtifactKeyValue` model and corresponding database table to
      store data associated with a user and an artifact.
    - A new `ArtifactKeyValuesController` provides a RESTful API for
      CRUD operations (`index`, `set`, `destroy`) on the key-value data.
    - Permissions are enforced: users can only modify their own data but
      can view public data from other users.

2.  **Secure JavaScript Bridge**:
    - A `postMessage` communication bridge is established between the
      sandboxed artifact `iframe` and the parent Discourse window.
    - A JavaScript API is exposed to the artifact as `window.discourseArtifact`
      with async methods: `get(key)`, `set(key, value, options)`,
      `delete(key)`, and `index(filter)`.
    - The parent window handles these requests, makes authenticated calls to the
      new controller, and returns the results to the iframe. This ensures
      security by keeping untrusted JS isolated.

3.  **AI Tool Integration**:
    - The `create_artifact` tool is updated with a `requires_storage`
      boolean parameter.
    - If an artifact requires storage, its metadata is flagged, and the
      system prompt for the code-generating AI is augmented with detailed
      documentation for the new storage API.

4.  **Configuration**:
    - Adds hidden site settings `ai_artifact_kv_value_max_length` and
      `ai_artifact_max_keys_per_user_per_artifact` for throttling.

This also includes a minor fix to use `jsonb_set` when updating
artifact metadata, ensuring other metadata fields are preserved.
2025-06-11 06:59:46 +10:00
Roman Rizzi f7e0ea888d DEV: Use a PORO to represent modules/features. (#1421)
Additional changes:

Adds a "#features" method in AiPersona to find which features are using that persona.
Serializes a basic version of a LlmModel in the persona's "#default_llm" serializer attribute.
2025-06-10 14:37:53 -03:00
Rafael dos Santos Silva b54db133cd FIX: No need for XML in gists responses anymore (#1420) 2025-06-10 14:21:31 -03:00
Guhyoun Nam cb94fa5e1c DEV: Add condition for force-conversations-sidebar AppEvent (#1418) 2025-06-10 11:29:38 -05:00
Roman Rizzi 98afd7f8c3 FEATURE: Display features that rely on multiple personas. (#1411)
* FEATURE: Display features that rely on multiple personas.

This change makes the previously hidden feature page visible while displaying features, like the AI helper, which relies on multiple personas.

* Fix system specs
2025-06-09 16:13:09 -03:00
Keegan George 33fd6801e5 DEV: Add back validator for Spam setting (#1415)
## 🔍 Overview
This update re-introduces the validator used on the `ai_spam_detection_enabled` setting. It was initially added here: https://github.com/discourse/discourse-ai/pull/1374 to prevent Spam from being enabled without creating an `AiModerationSetting` value in the database. However, due to issues with backups/migrations we temporarily removed it here: https://github.com/discourse/discourse-ai/pull/1393. Now with some internal fixes, we can re-introduce it. We also update the validator so that it only validates when trying to turn on rather than when turning off too.
2025-06-06 10:56:36 -07:00
Natalie Tay 6827147362 DEV: Add topic and post id when using completions for traceability to AiApiAuditLog (#1414)
The AiApiAuditLog per translation event doesn't trace back easily to a post or topic.

This commit adds support to that, and also switches the translators to named arguments rather than positional arguments.
2025-06-06 23:24:24 +08:00
Natalie Tay 8a3a247b11 DEV: Also detect locale of categories and do not translate if already in the locale (#1413)
Previously I had omitted to add `locale` to the category, as categories tended to be just a single word, and I did not find it would be worth to carry locale information.

Due to certain LLMs that do poorer at translation, category descriptions got pretty messy. We added locale support here - https://github.com/discourse/discourse/pull/32962. 

This PR adds the automatic locale detection, and skips translating to the category's locale.
2025-06-06 22:41:48 +08:00
Sam 6817866de9 FEATURE: allow access to assigns from forum researcher (#1412)
* FEATURE: allow access to assigns from forum researcher

* FIX: should properly be checking for empty

* finish PR
2025-06-06 16:59:00 +10:00
Mark VanLandingham 7d96739aab DEV: Add body class on ai-bot-conversations page (#1410) 2025-06-05 10:29:40 -06:00
Roman Rizzi a68ab76eb6 FIX: Update topic summarization prompt to work better when using full names (#1409) 2025-06-05 12:28:29 -03:00
Martin Brennan 1fb358a9d0 UX: Style tweaks for RAG uploader and form width (#1407)
This commit changes the RAG uploader form elements to
be @format="full" instead of doing a hardcoded 500px width,
which was causing a horizontal scrollbar in the tools form
on mobile.

Also, it moves the 80% max width for the tools form into the
new viewport CSS API, and only applies it on desktop, because
this was also causing width issues on mobile.
2025-06-05 12:40:00 +10:00
Sam b3d78a6a10 FIX: when tool options are added they should be available (#1406)
Fixes a regression where tool option editor was not showing
all tools
2025-06-05 12:05:55 +10:00
Roman Rizzi c885e5697f review feedback 2025-06-04 14:23:00 -03:00
Roman Rizzi 0338dbea23 FEATURE: Use different personas to power AI helper features.
You can now edit each AI helper prompt individually through personas, limit access to specific groups, set different LLMs, etc.
2025-06-04 14:23:00 -03:00
David Taylor cab39839fd Revert "DEV: Patch Net::BufferedIO to help debug spec flakes (#1375)" (#1403)
This reverts commit ca78b1a1c5.

Problem resolved by https://github.com/discourse/discourse-perspective-api/pull/110
2025-06-04 14:13:45 +01:00
Jarek Radosz 2842295b10 DEV: Remove duplicated i18n entries (#1402)
Regressed in 3e74eea1e5
2025-06-04 15:13:12 +02:00
Sam 3e74eea1e5 FEATURE: add context and llm controls to researcher, fix username filter (#1401)
Adds context length controls to researcher (max tokens per post and batch)
Allow picking LLM for researcher
Fix bug where unicode usernames were not working
Fix documentation of OR logic
2025-06-04 16:39:43 +10:00
Kris 4f980d5514 FIX: always render "today" on top of conversation sidebar (#1400) 2025-06-03 14:52:12 -04:00
Discourse Translator Bot 59f4b66ede Update translations (#1395) 2025-06-03 17:37:22 +02:00
Kris fa51e9d948 REFACTOR: update AI conversation sidebar to use sidebar sections for date grouping (#1389) 2025-06-03 09:40:52 -05:00
Joffrey JAFFEUX 306fec2b24 FIX: edit-topic is not invisible on desktop (#1394)
Fix due to https://github.com/discourse/discourse/pull/32941
2025-06-03 16:30:19 +02:00
Keegan George 49fe986974 FIX: unable to scroll on mobile AI post helper results (#1396)
## 🔍 Overview

As reported on [Meta](https://meta.discourse.org/t/when-selecting-text-and-giving-ai-a-custom-prompt-based-on-the-text-the-popup-cannot-be-scrolled/368687?u=keegan), you are currently unable to scroll on long AI post helper results when on mobile. This update fixes that by allowing scroll back. No test as it's tricky to test scroll behavior.

## 🔗 Relevant Links
https://meta.discourse.org/t/when-selecting-text-and-giving-ai-a-custom-prompt-based-on-the-text-the-popup-cannot-be-scrolled/368687

## 📹 Screen Recording

### ← Before
https://github.com/user-attachments/assets/9dc6f6a2-b9d4-46a8-91ee-ad1ed6f47de0


### → After
https://github.com/user-attachments/assets/ca93dba1-eefb-4d03-9b47-c4a8ba291117
2025-06-03 06:34:22 -07:00
Sam 4dffd0b2c5 DEV: improve tool infra, improve forum researcher prompts, improve logging (#1391)
- add sleep function for tool polling with rate limits
- Support base64 encoding for HTTP requests and uploads
-  Enhance forum researcher with cost warnings and comprehensive planning
- Add cancellation support for research operations
- Include feature_name parameter for bot analytics
- richer research support (OR queries)
2025-06-03 15:17:55 +10:00
Keegan George 4c0660d6fd DEV: Remove validator for spam (#1393) 2025-06-02 13:57:34 -07:00
Rafael dos Santos Silva 27de71fc4f FIX: Proper default LLM detection for inferred concepts (#1392) 2025-06-02 17:56:47 -03:00
478f31de47 FEATURE: add inferred concepts system (#1330)
* FEATURE: add inferred concepts system

This commit adds a new inferred concepts system that:
- Creates a model for storing concept labels that can be applied to topics
- Provides AI personas for finding new concepts and matching existing ones
- Adds jobs for generating concepts from popular topics
- Includes a scheduled job that automatically processes engaging topics

* FEATURE: Extend inferred concepts to include posts

* Adds support for concepts to be inferred from and applied to posts
* Replaces daily task with one that handles both topics and posts
* Adds database migration for posts_inferred_concepts join table
* Updates PersonaContext to include inferred concepts



Co-authored-by: Roman Rizzi <[email protected]>
Co-authored-by: Keegan George <[email protected]>
2025-06-02 14:29:20 -03:00
David Taylor 4ce8973e56 PERF: Optimize .ai-debug-modal__tokens selector (#1390)
This is showing as the most expensive CSS selector in Discourse at the moment. Adding specific classes and dropping the general `span` selector will make this much cheaper.
2025-05-30 21:47:30 +01:00
Keegan George 34c98de864 FIX: Exporting overall sentiment fails (#1388)
## 🔍 Overview

When exporting an Overall Sentiment report in the admin panel, the export fails with:

```ruby
Job exception: no implicit conversion of Symbol into Integer
```

This was happening because we are passing a single _Hash_ to `report.data` however, exports expect `report.data` to be an _Array of Hashes_. This update fixes this issue by wrapping the data in an array.
2025-05-30 11:58:28 -07:00
Keegan George 38f7e9c2c4 UX: AI composer helper refinements (#1387)
This update includes a variety of small refinements to the AI composer helper:

- prevent height jump when going from loading text placeholder → proofreading text streaming
- update padding on AI helper options list to be more suitable with typical Discourse menu design
- for composer helper results that are not `showResultAsDiff` (i.e. translation):
   - update before/after diff design to be more subtle
   - results should be in normal font (as the text is cooked and not raw markdown)
- fix: smooth streaming animation stuck showing dot icon even after smooth streaming is done
2025-05-30 10:35:53 -07:00
Sam e6876aabd5 FIX: enum handling needs to be done on save as well (#1386) 2025-05-30 17:58:16 +10:00
Sam b5d393b4bc FIX: custom tools incorrectly setting all fields to blank enum (#1385)
Previous to this change, enum was set to [] which broke all non
enum tools
2025-05-30 17:12:24 +10:00
Sam 77ae426d95 FEATURE: support upload.getUrl in custom tools (#1384)
* FEATURE: support upload.getUrl in custom tools

Some tools need to share images with an API. A common pattern
is for APIs to expect a URL.

This allows converting upload://123123 to a proper CDN friendly
URL from within a custom tool

* no support for secure uploads, so be explicit about it.
2025-05-30 15:47:07 +10:00
Alan Guo Xiang Tan 9f43df0302 FIX: Full page search broken (#1383)
Follow-up to 59d6e2b467
2025-05-30 10:07:48 +08:00
David Taylor 59d6e2b467 DEV: Replace narrowDesktopView logic with viewport API (#1372) 2025-05-30 06:52:39 +10:00
SamandKeegan George c06d7b07d5 FEATURE: simplify streaming implementation - rush last update (#1380)
* FEATURE: simplify streaming implementation - rush last update

Previous to this change we would simply "flash" the final update
on the screen, this amends it so we quickly update the UI in about
1 second in the end with all the final update.

This makes the UI feel more interactive to end users.

* DEV: Updates...

- Remove unnecessary comments
- Add doc style comments for all methods
- Organize methods (private at bottom)
- Update some variable names

---------

Co-authored-by: Keegan George <[email protected]>
2025-05-30 06:50:12 +10:00
Natalie Tay 373e2305d6 FEATURE: Automatic translation and localization of posts, topics, categories (#1376)
Related: https://github.com/discourse/discourse-translator/pull/310

This commit includes all the jobs and event hooks to localize posts, topics, and categories.

A few notes:
- `feature_name: "translation"` because the site setting is `ai-translation` and module is `Translation`
- we will switch to proper ai-feature in the near future, and can consider using the persona_user as `localization.localizer_user_id`
- keeping things flat within the module for now as we will be moving to ai-feature soon and have to rearrange
- Settings renamed/introduced are:
  - ai_translation_backfill_rate (0)
  - ai_translation_backfill_limit_to_public_content (true)
  - ai_translation_backfill_max_age_days (5)
  - ai_translation_verbose_logs (false)
2025-05-29 17:28:06 +08:00
Guhyoun Nam ad5c48d9ae DEV: Add appEvents trigger for AI New Question button (#1379)
* DEV: Add appEvents trigger for AI New Question button

* appEvent name update
2025-05-28 13:26:37 -05:00
Keegan George d99c335dab DEV: Ensure enabling/disabling spam is set and logged (#1378)
Since we enable/disable `ai_spam_detection_enabled` setting in a custom Spam tab UI in AI, we want to ensure we retain the setting and logging features. To preserve that, we want to update the controller to use `SiteSetting.set_and_log` instead of setting the value directly.
2025-05-28 10:12:21 -07:00
Roman Rizzi 01e29ca5d8 FIX: Bump persona's examples length (#1377) 2025-05-28 14:01:44 -03:00
Keegan George 297c64c3b8 DEV: Unhide spam detection setting (#1374)
## 🔍 Overview
We want to unhide `ai_spam_detection_enabled` setting so that we can retain staff action log features. However, we also want to ensure users cannot enable spam detection without having `AiModerationSetting.spam` present in the database.

In this update we unhide the setting, but also add a validator to ensure the necessary configuration is in place before allowing the setting to be enabled.
2025-05-28 07:50:56 -07:00
David Taylor ca78b1a1c5 DEV: Patch Net::BufferedIO to help debug spec flakes (#1375)
Internal `/t/154170`
2025-05-28 10:24:07 +01:00
433 changed files with 19530 additions and 3375705 deletions
+2
View File
@@ -1,3 +1,5 @@
< 3.5.0.beta7-dev: cd14b0c0bee0cf63c59b64b6f7213e31a37f11a7
< 3.5.0.beta6-dev: 3e74eea1e5e3143888d67a8d8a11206df214dc24
< 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec < 3.5.0.beta3-dev: 09a68414804a1447f52e5d60691ba59742cda9ec
< 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec < 3.5.0.beta2-dev: de8624416a15b3d8e7ad350b083cc1420451ccec
< 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756 < 3.5.0.beta1-dev: bdef136080074a993e7c4f5ca562edc31a8ba756
-1
View File
@@ -6,5 +6,4 @@ evals/log
evals/cases evals/cases
config/eval-llms.local.yml config/eval-llms.local.yml
# this gets rid of search results from ag, ripgrep, etc # this gets rid of search results from ag, ripgrep, etc
tokenizers/
public/ai-share/highlight.min.js public/ai-share/highlight.min.js
+1 -1
View File
@@ -34,7 +34,7 @@ GEM
racc racc
prettier_print (1.2.1) prettier_print (1.2.1)
racc (1.8.1) racc (1.8.1)
rack (3.1.14) rack (3.1.16)
rainbow (3.1.1) rainbow (3.1.1)
regexp_parser (2.10.0) regexp_parser (2.10.0)
rubocop (1.74.0) rubocop (1.74.0)
@@ -1,3 +1,4 @@
import { action } from "@ember/object";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import DiscourseRoute from "discourse/routes/discourse"; import DiscourseRoute from "discourse/routes/discourse";
import SiteSetting from "admin/models/site-setting"; import SiteSetting from "admin/models/site-setting";
@@ -12,7 +13,7 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
const { site_settings } = await ajax("/admin/config/site_settings.json", { const { site_settings } = await ajax("/admin/config/site_settings.json", {
data: { data: {
filter_area: `ai-features/${currentFeature.ref}`, filter_area: `ai-features/${currentFeature.module_name}`,
plugin: "discourse-ai", plugin: "discourse-ai",
category: "discourse_ai", category: "discourse_ai",
}, },
@@ -24,4 +25,11 @@ export default class AdminPluginsShowDiscourseAiFeaturesEdit extends DiscourseRo
return currentFeature; return currentFeature;
} }
@action
willTransition() {
// site settings may amend if a feature is enabled or disabled, so refresh the model
// even on back button
this.router.refresh("adminPlugins.show.discourse-ai-features");
}
} }
@@ -1,156 +0,0 @@
import Component from "@glimmer/component";
import { service } from "@ember/service";
import RouteTemplate from "ember-route-template";
import { gt } from "truth-helpers";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import { i18n } from "discourse-i18n";
export default RouteTemplate(
class extends Component {
@service adminPluginNavManager;
get tableHeaders() {
const prefix = "discourse_ai.features.list.header";
return [
i18n(`${prefix}.name`),
i18n(`${prefix}.persona`),
i18n(`${prefix}.groups`),
"",
];
}
get configuredFeatures() {
return this.args.model.filter(
(feature) => feature.enable_setting.value === true
);
}
get unconfiguredFeatures() {
return this.args.model.filter(
(feature) => feature.enable_setting.value === false
);
}
<template>
<DBreadcrumbsItem
@path="/admin/plugins/{{this.adminPluginNavManager.currentPlugin.name}}/ai-features"
@label={{i18n "discourse_ai.features.short_title"}}
/>
<section class="ai-feature-list admin-detail">
<DPageSubheader
@titleLabel={{i18n "discourse_ai.features.short_title"}}
@descriptionLabel={{i18n "discourse_ai.features.description"}}
@learnMoreUrl="todo"
/>
{{#if (gt this.configuredFeatures.length 0)}}
<div class="ai-feature-list__configured-features">
<h3>{{i18n "discourse_ai.features.list.configured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
{{#each this.tableHeaders as |header|}}
<th>{{header}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each this.configuredFeatures as |feature|}}
<tr
class="ai-feature-list__row d-admin-row__content"
data-feature-name={{feature.name}}
>
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__persona"
>
<DButton
class="btn-flat btn-small ai-feature-list__row-item-persona"
@translatedLabel={{feature.persona.name}}
@route="adminPlugins.show.discourse-ai-personas.edit"
@routeModels={{feature.persona.id}}
/>
</td>
<td
class="d-admin-row__detail ai-feature-list__row-item ai-feature-list__groups"
>
{{#if (gt feature.persona.allowed_groups.length 0)}}
<ul class="ai-feature-list__row-item-groups">
{{#each feature.persona.allowed_groups as |group|}}
<li>{{group.name}}</li>
{{/each}}
</ul>
{{/if}}
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small edit"
@label="discourse_ai.features.list.edit"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
{{#if (gt this.unconfiguredFeatures.length 0)}}
<div class="ai-feature-list__unconfigured-features">
<h3>{{i18n "discourse_ai.features.list.unconfigured_features"}}</h3>
<table class="d-admin-table">
<thead>
<tr>
<th>{{i18n "discourse_ai.features.list.header.name"}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each this.unconfiguredFeatures as |feature|}}
<tr class="ai-feature-list__row d-admin-row__content">
<td class="d-admin-row__overview ai-feature-list__row-item">
<span class="ai-feature-list__row-item-name">
<strong>
{{feature.name}}
</strong>
</span>
<span class="ai-feature-list__row-item-description">
{{feature.description}}
</span>
</td>
<td class="d-admin-row_controls">
<DButton
class="btn-small"
@label="discourse_ai.features.list.set_up"
@route="adminPlugins.show.discourse-ai-features.edit"
@routeModels={{feature.id}}
/>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/if}}
</section>
</template>
}
);
@@ -0,0 +1 @@
<AiFeatures @features={{this.model}} />
@@ -40,6 +40,7 @@ module DiscourseAi
embedding_def = EmbeddingDefinition.new(ai_embeddings_params) embedding_def = EmbeddingDefinition.new(ai_embeddings_params)
if embedding_def.save if embedding_def.save
log_ai_embedding_creation(embedding_def)
render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created render json: AiEmbeddingDefinitionSerializer.new(embedding_def), status: :created
else else
render_json_error embedding_def render_json_error embedding_def
@@ -55,7 +56,10 @@ module DiscourseAi
) )
end end
initial_attributes = embedding_def.attributes.dup
if embedding_def.update(ai_embeddings_params.except(:dimensions)) if embedding_def.update(ai_embeddings_params.except(:dimensions))
log_ai_embedding_update(embedding_def, initial_attributes)
render json: AiEmbeddingDefinitionSerializer.new(embedding_def) render json: AiEmbeddingDefinitionSerializer.new(embedding_def)
else else
render_json_error embedding_def render_json_error embedding_def
@@ -75,7 +79,16 @@ module DiscourseAi
return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409) return render_json_error(I18n.t("discourse_ai.embeddings.delete_failed"), status: 409)
end end
embedding_details = {
embedding_id: embedding_def.id,
display_name: embedding_def.display_name,
provider: embedding_def.provider,
dimensions: embedding_def.dimensions,
subject: embedding_def.display_name,
}
if embedding_def.destroy if embedding_def.destroy
log_ai_embedding_deletion(embedding_details)
head :no_content head :no_content
else else
render_json_error embedding_def render_json_error embedding_def
@@ -128,6 +141,60 @@ module DiscourseAi
permitted permitted
end end
def ai_embeddings_logger_fields
{
display_name: {
},
provider: {
},
dimensions: {
},
url: {
},
tokenizer_class: {
},
max_sequence_length: {
},
embed_prompt: {
type: :large_text,
},
search_prompt: {
type: :large_text,
},
matryoshka_dimensions: {
},
api_key: {
type: :sensitive,
},
# JSON fields should be tracked as simple changes
json_fields: [:provider_params],
}
end
def log_ai_embedding_creation(embedding_def)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
logger.log_creation("embedding", embedding_def, ai_embeddings_logger_fields, entity_details)
end
def log_ai_embedding_update(embedding_def, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { embedding_id: embedding_def.id, subject: embedding_def.display_name }
logger.log_update(
"embedding",
embedding_def,
initial_attributes,
ai_embeddings_logger_fields,
entity_details,
)
end
def log_ai_embedding_deletion(embedding_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_deletion("embedding", embedding_details)
end
end end
end end
end end
@@ -6,24 +6,44 @@ module DiscourseAi
requires_plugin ::DiscourseAi::PLUGIN_NAME requires_plugin ::DiscourseAi::PLUGIN_NAME
def index def index
render json: serialize_features(DiscourseAi::Features.features) render json: serialize_modules(DiscourseAi::Configuration::Module.all)
end end
def edit def edit
raise Discourse::InvalidParameters.new(:id) if params[:id].blank? raise Discourse::InvalidParameters.new(:id) if params[:id].blank?
render json: serialize_feature(DiscourseAi::Features.find_feature_by_id(params[:id].to_i))
a_module = DiscourseAi::Configuration::Module.find_by(id: params[:id].to_i)
render json: serialize_module(a_module)
end end
private private
def serialize_features(features) def serialize_modules(modules)
features.map { |feature| feature.merge(persona: serialize_persona(feature[:persona])) } modules.map { |a_module| serialize_module(a_module) }
end
def serialize_module(a_module)
return nil if a_module.blank?
{
id: a_module.id,
module_name: a_module.name,
module_enabled: a_module.enabled?,
features: a_module.features.map { |f| serialize_feature(f) },
}
end end
def serialize_feature(feature) def serialize_feature(feature)
return nil if feature.blank? {
name: feature.name,
feature.merge(persona: serialize_persona(feature[:persona])) personas: feature.persona_ids.map { |id| serialize_persona(persona_id_obj_hash[id]) },
llm_models:
feature.llm_models.map do |llm_model|
{ id: llm_model.id, name: llm_model.display_name }
end,
enabled: feature.enabled?,
}
end end
def serialize_persona(persona) def serialize_persona(persona)
@@ -31,6 +51,16 @@ module DiscourseAi
serialize_data(persona, AiFeaturesPersonaSerializer, root: false) serialize_data(persona, AiFeaturesPersonaSerializer, root: false)
end end
private
def persona_id_obj_hash
@persona_id_obj_hash ||=
begin
ids = DiscourseAi::Configuration::Feature.all.map(&:persona_ids).flatten.uniq
AiPersona.where(id: ids).index_by(&:id)
end
end
end end
end end
end end
@@ -47,6 +47,7 @@ module DiscourseAi
if llm_model.save if llm_model.save
llm_model.toggle_companion_user llm_model.toggle_companion_user
log_llm_model_creation(llm_model)
render json: LlmModelSerializer.new(llm_model), status: :created render json: LlmModelSerializer.new(llm_model), status: :created
else else
render_json_error llm_model render_json_error llm_model
@@ -56,6 +57,10 @@ module DiscourseAi
def update def update
llm_model = LlmModel.find(params[:id]) llm_model = LlmModel.find(params[:id])
# Capture initial state for logging
initial_attributes = llm_model.attributes.dup
initial_quotas = llm_model.llm_quotas.map(&:attributes)
if params[:ai_llm].key?(:llm_quotas) if params[:ai_llm].key?(:llm_quotas)
if quota_params if quota_params
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id) existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
@@ -81,6 +86,7 @@ module DiscourseAi
if llm_model.update(ai_llm_params(updating: llm_model)) if llm_model.update(ai_llm_params(updating: llm_model))
llm_model.toggle_companion_user llm_model.toggle_companion_user
log_llm_model_update(llm_model, initial_attributes, initial_quotas)
render json: LlmModelSerializer.new(llm_model) render json: LlmModelSerializer.new(llm_model)
else else
render_json_error llm_model render_json_error llm_model
@@ -109,11 +115,20 @@ module DiscourseAi
) )
end end
# Capture model details for logging before destruction
model_details = {
model_id: llm_model.id,
display_name: llm_model.display_name,
name: llm_model.name,
provider: llm_model.provider,
}
# Clean up companion users # Clean up companion users
llm_model.enabled_chat_bot = false llm_model.enabled_chat_bot = false
llm_model.toggle_companion_user llm_model.toggle_companion_user
if llm_model.destroy if llm_model.destroy
log_llm_model_deletion(model_details)
head :no_content head :no_content
else else
render_json_error llm_model render_json_error llm_model
@@ -190,6 +205,89 @@ module DiscourseAi
permitted permitted
end end
def ai_llm_logger_fields
{
display_name: {
},
name: {
},
provider: {
},
tokenizer: {
},
url: {
},
max_prompt_tokens: {
},
max_output_tokens: {
},
enabled_chat_bot: {
},
vision_enabled: {
},
api_key: {
type: :sensitive,
},
input_cost: {
},
output_cost: {
},
# JSON fields should be tracked as simple changes
json_fields: [:provider_params],
}
end
def log_llm_model_creation(llm_model)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
# Add quota information as a special case
if llm_model.llm_quotas.any?
entity_details[:quotas] = llm_model
.llm_quotas
.map do |quota|
"Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s"
end
.join("; ")
end
logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details)
end
def log_llm_model_update(llm_model, initial_attributes, initial_quotas)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
# Track quota changes separately as they're a special case
current_quotas = llm_model.llm_quotas.reload.map(&:attributes)
if initial_quotas != current_quotas
initial_quota_summary =
initial_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
current_quota_summary =
current_quotas
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
.join("; ")
entity_details[:quotas_changed] = true
entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}"
end
logger.log_update(
"llm_model",
llm_model,
initial_attributes,
ai_llm_logger_fields,
entity_details,
)
end
def log_llm_model_deletion(model_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
model_details[:subject] = model_details[:display_name]
logger.log_deletion("llm_model", model_details)
end
end end
end end
end end
@@ -5,19 +5,20 @@ module DiscourseAi
class AiPersonasController < ::Admin::AdminController class AiPersonasController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_persona, only: %i[edit update destroy create_user] before_action :find_ai_persona, only: %i[edit update destroy create_user export]
def index def index
ai_personas = ai_personas =
AiPersona.ordered.map do |persona| AiPersona
# we use a special serializer here cause names and descriptions are .ordered
# localized for system personas .includes(:user, :uploads)
LocalizedAiPersonaSerializer.new(persona, root: false) .map { |persona| LocalizedAiPersonaSerializer.new(persona, root: false) }
end
tools = tools =
DiscourseAi::Personas::Persona.all_available_tools.map do |tool| DiscourseAi::Personas::Persona.all_available_tools.map do |tool|
AiToolSerializer.new(tool, root: false) AiToolSerializer.new(tool, root: false)
end end
AiTool AiTool
.where(enabled: true) .where(enabled: true)
.each do |tool| .each do |tool|
@@ -31,10 +32,12 @@ module DiscourseAi
), ),
} }
end end
llms = llms =
DiscourseAi::Configuration::LlmEnumerator.values_for_serialization( DiscourseAi::Configuration::LlmEnumerator.values_for_serialization(
allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map, allowed_seeded_llm_ids: SiteSetting.ai_bot_allowed_seeded_models_map,
) )
render json: { render json: {
ai_personas: ai_personas, ai_personas: ai_personas,
meta: { meta: {
@@ -58,6 +61,7 @@ module DiscourseAi
ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads)) ai_persona = AiPersona.new(ai_persona_params.except(:rag_uploads))
if ai_persona.save if ai_persona.save
RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids) RagDocumentFragment.link_target_and_uploads(ai_persona, attached_upload_ids)
log_ai_persona_creation(ai_persona)
render json: { render json: {
ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false), ai_persona: LocalizedAiPersonaSerializer.new(ai_persona, root: false),
@@ -74,8 +78,11 @@ module DiscourseAi
end end
def update def update
initial_attributes = @ai_persona.attributes.dup
if @ai_persona.update(ai_persona_params.except(:rag_uploads)) if @ai_persona.update(ai_persona_params.except(:rag_uploads))
RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids) RagDocumentFragment.update_target_uploads(@ai_persona, attached_upload_ids)
log_ai_persona_update(@ai_persona, initial_attributes)
render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false) render json: LocalizedAiPersonaSerializer.new(@ai_persona, root: false)
else else
@@ -84,13 +91,57 @@ module DiscourseAi
end end
def destroy def destroy
persona_details = {
persona_id: @ai_persona.id,
name: @ai_persona.name,
description: @ai_persona.description,
}
if @ai_persona.destroy if @ai_persona.destroy
log_ai_persona_deletion(persona_details)
head :no_content head :no_content
else else
render_json_error @ai_persona render_json_error @ai_persona
end end
end end
def export
persona = AiPersona.find(params[:id])
exporter = DiscourseAi::PersonaExporter.new(persona: persona)
response.headers[
"Content-Disposition"
] = "attachment; filename=\"#{persona.name.parameterize}.json\""
render json: exporter.export
end
def import
name = params.dig(:persona, :name)
existing_persona = AiPersona.find_by(name: name)
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
begin
importer = DiscourseAi::PersonaImporter.new(json: params.to_unsafe_h)
if existing_persona && force_update
initial_attributes = existing_persona.attributes.dup
persona = importer.import!(overwrite: true)
log_ai_persona_update(persona, initial_attributes)
render json: LocalizedAiPersonaSerializer.new(persona, root: false)
else
persona = importer.import!
log_ai_persona_creation(persona)
render json: LocalizedAiPersonaSerializer.new(persona, root: false), status: :created
end
rescue DiscourseAi::PersonaImporter::ImportError => e
render_json_error e.message, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error("AI Persona import failed: #{e.message}")
render_json_error "Import failed: #{e.message}", status: :unprocessable_entity
end
end
def stream_reply def stream_reply
persona = persona =
AiPersona.find_by(name: params[:persona_name]) || AiPersona.find_by(name: params[:persona_name]) ||
@@ -261,6 +312,92 @@ module DiscourseAi
examples.map { |example_arr| example_arr.take(2).map(&:to_s) } examples.map { |example_arr| example_arr.take(2).map(&:to_s) }
end end
def ai_persona_logger_fields
{
name: {
},
description: {
},
enabled: {
},
priority: {
},
system_prompt: {
type: :large_text,
},
default_llm_id: {
},
temperature: {
},
top_p: {
},
user_id: {
},
max_context_posts: {
},
vision_enabled: {
},
vision_max_pixels: {
},
rag_chunk_tokens: {
},
rag_chunk_overlap_tokens: {
},
rag_conversation_chunks: {
},
rag_llm_model_id: {
},
question_consolidator_llm_id: {
},
allow_chat_channel_mentions: {
},
allow_chat_direct_messages: {
},
allow_topic_mentions: {
},
allow_personal_messages: {
},
tool_details: {
type: :large_text,
},
forced_tool_count: {
},
force_default_llm: {
},
# JSON fields
json_fields: %i[tools response_format examples allowed_group_ids],
}
end
def log_ai_persona_creation(ai_persona)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
entity_details[:tools_count] = (ai_persona.tools || []).size
logger.log_creation("persona", ai_persona, ai_persona_logger_fields, entity_details)
end
def log_ai_persona_update(ai_persona, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { persona_id: ai_persona.id, subject: ai_persona.name }
entity_details[:tools_count] = ai_persona.tools.size if ai_persona.tools.present?
logger.log_update(
"persona",
ai_persona,
initial_attributes,
ai_persona_logger_fields,
entity_details,
)
end
def log_ai_persona_deletion(persona_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
persona_details[:subject] = persona_details[:name]
logger.log_deletion("persona", persona_details)
end
end end
end end
end end
@@ -10,6 +10,17 @@ module DiscourseAi
end end
def update def update
initial_settings = AiModerationSetting.spam
initial_data = {
custom_instructions: initial_settings&.data&.dig("custom_instructions"),
llm_model_id: initial_settings&.llm_model_id,
ai_persona_id: initial_settings&.ai_persona_id,
}
initial_custom_instructions = initial_settings&.data&.dig("custom_instructions")
initial_llm_model_id = initial_settings&.llm_model_id
updated_params = {} updated_params = {}
if allowed_params.key?(:llm_model_id) if allowed_params.key?(:llm_model_id)
llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id] llm_model_id = updated_params[:llm_model_id] = allowed_params[:llm_model_id]
@@ -25,6 +36,22 @@ module DiscourseAi
) )
end end
end end
if allowed_params.key?(:ai_persona_id)
updated_params[:ai_persona_id] = allowed_params[:ai_persona_id]
persona = AiPersona.find_by(id: allowed_params[:ai_persona_id])
if persona.nil? ||
persona.response_format.to_a.none? { |rf|
rf["key"] == "spam" && rf["type"] == "boolean"
}
return(
render_json_error(
I18n.t("discourse_ai.llm.configuration.invalid_persona_response_format"),
status: 422,
)
)
end
end
updated_params[:data] = { updated_params[:data] = {
custom_instructions: allowed_params[:custom_instructions], custom_instructions: allowed_params[:custom_instructions],
} if allowed_params.key?(:custom_instructions) } if allowed_params.key?(:custom_instructions)
@@ -36,6 +63,8 @@ module DiscourseAi
else else
AiModerationSetting.create!(updated_params.merge(setting_type: :spam)) AiModerationSetting.create!(updated_params.merge(setting_type: :spam))
end end
log_ai_spam_update(initial_data, allowed_params)
end end
is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled]) is_enabled = ActiveModel::Type::Boolean.new.cast(allowed_params[:is_enabled])
@@ -50,7 +79,7 @@ module DiscourseAi
) )
end end
SiteSetting.ai_spam_detection_enabled = is_enabled SiteSetting.set_and_log("ai_spam_detection_enabled", is_enabled, current_user)
end end
render json: AiSpamSerializer.new(spam_config, root: false) render json: AiSpamSerializer.new(spam_config, root: false)
@@ -113,8 +142,44 @@ module DiscourseAi
private private
def log_ai_spam_update(initial_data, params)
changes_to_log = {}
initial_llm_model_id = initial_data[:llm_model_id]
if params.key?(:llm_model_id) && initial_llm_model_id.to_s != params[:llm_model_id].to_s
old_model_name =
LlmModel.find_by(id: initial_llm_model_id)&.display_name || initial_llm_model_id
new_model_name =
LlmModel.find_by(id: params[:llm_model_id])&.display_name || params[:llm_model_id]
changes_to_log[:llm_model_id] = "#{old_model_name} → #{new_model_name}"
end
initial_custom_instructions = initial_data[:custom_instructions]
if params.key?(:custom_instructions) &&
initial_custom_instructions != params[:custom_instructions]
changes_to_log[:custom_instructions] = params[:custom_instructions]
end
initial_ai_persona_id = initial_data[:ai_persona_id]
if params.key?(:ai_persona_id) && initial_ai_persona_id.to_s != params[:ai_persona_id].to_s
old_persona_name =
AiPersona.find_by(id: initial_ai_persona_id)&.name || initial_ai_persona_id
new_persona_name =
AiPersona.find_by(id: params[:ai_persona_id])&.name || params[:ai_persona_id]
changes_to_log[:ai_persona_id] = "#{old_persona_name} → #{new_persona_name}"
end
if changes_to_log.present?
changes_to_log[:subject] = I18n.t("discourse_ai.spam_detection.logging_subject")
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_custom("update_ai_spam_settings", changes_to_log)
end
end
def allowed_params def allowed_params
params.permit(:is_enabled, :llm_model_id, :custom_instructions) params.permit(:is_enabled, :llm_model_id, :custom_instructions, :ai_persona_id)
end end
def spam_config def spam_config
@@ -5,7 +5,7 @@ module DiscourseAi
class AiToolsController < ::Admin::AdminController class AiToolsController < ::Admin::AdminController
requires_plugin ::DiscourseAi::PLUGIN_NAME requires_plugin ::DiscourseAi::PLUGIN_NAME
before_action :find_ai_tool, only: %i[test edit update destroy] before_action :find_ai_tool, only: %i[test edit update destroy export]
def index def index
ai_tools = AiTool.all ai_tools = AiTool.all
@@ -25,15 +25,58 @@ module DiscourseAi
if ai_tool.save if ai_tool.save
RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids) RagDocumentFragment.link_target_and_uploads(ai_tool, attached_upload_ids)
log_ai_tool_creation(ai_tool)
render_serialized(ai_tool, AiCustomToolSerializer, status: :created) render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
else else
render_json_error ai_tool render_json_error ai_tool
end end
end end
def export
response.headers[
"Content-Disposition"
] = "attachment; filename=\"#{@ai_tool.tool_name}.json\""
render_serialized(@ai_tool, AiCustomToolSerializer)
end
def import
existing_tool = AiTool.find_by(tool_name: ai_tool_params[:tool_name])
force_update = params[:force].present? && params[:force].to_s.downcase == "true"
if existing_tool && !force_update
return(
render_json_error "Tool with tool_name '#{ai_tool_params[:tool_name]}' already exists. Use force=true to overwrite.",
status: :conflict
)
end
if existing_tool && force_update
initial_attributes = existing_tool.attributes.dup
if existing_tool.update(ai_tool_params)
log_ai_tool_update(existing_tool, initial_attributes)
render_serialized(existing_tool, AiCustomToolSerializer)
else
render_json_error existing_tool
end
else
ai_tool = AiTool.new(ai_tool_params)
ai_tool.created_by_id = current_user.id
if ai_tool.save
log_ai_tool_creation(ai_tool)
render_serialized(ai_tool, AiCustomToolSerializer, status: :created)
else
render_json_error ai_tool
end
end
end
def update def update
initial_attributes = @ai_tool.attributes.dup
if @ai_tool.update(ai_tool_params) if @ai_tool.update(ai_tool_params)
RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids) RagDocumentFragment.update_target_uploads(@ai_tool, attached_upload_ids)
log_ai_tool_update(@ai_tool, initial_attributes)
render_serialized(@ai_tool, AiCustomToolSerializer) render_serialized(@ai_tool, AiCustomToolSerializer)
else else
render_json_error @ai_tool render_json_error @ai_tool
@@ -41,7 +84,15 @@ module DiscourseAi
end end
def destroy def destroy
tool_logger_details = {
tool_id: @ai_tool.id,
name: @ai_tool.name,
tool_name: @ai_tool.tool_name,
subject: @ai_tool.name,
}
if @ai_tool.destroy if @ai_tool.destroy
log_ai_tool_deletion(tool_logger_details)
head :no_content head :no_content
else else
render_json_error @ai_tool render_json_error @ai_tool
@@ -96,6 +147,60 @@ module DiscourseAi
) )
.except(:rag_uploads) .except(:rag_uploads)
end end
def ai_tool_logger_fields
{
name: {
},
tool_name: {
},
description: {
},
summary: {
},
enabled: {
},
rag_chunk_tokens: {
},
rag_chunk_overlap_tokens: {
},
rag_llm_model_id: {
},
script: {
type: :large_text,
},
parameters: {
type: :large_text,
},
}
end
def log_ai_tool_creation(ai_tool)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
entity_details[:parameter_count] = ai_tool.parameters.size if ai_tool.parameters.present?
logger.log_creation("tool", ai_tool, ai_tool_logger_fields, entity_details)
end
def log_ai_tool_update(ai_tool, initial_attributes)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
entity_details = { tool_id: ai_tool.id, subject: ai_tool.name }
logger.log_update(
"tool",
ai_tool,
initial_attributes,
ai_tool_logger_fields,
entity_details,
)
end
def log_ai_tool_deletion(tool_details)
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
logger.log_deletion("tool", tool_details)
end
end end
end end
end end
@@ -0,0 +1,124 @@
# frozen_string_literal: true
module DiscourseAi
module AiBot
class ArtifactKeyValuesController < ::ApplicationController
requires_plugin DiscourseAi::PLUGIN_NAME
before_action :ensure_logged_in, only: %i[set destroy]
before_action :find_artifact
PER_PAGE_MAX = 100
def index
page = index_params[:page].to_i
page = 1 if page < 1
per_page = index_params[:per_page].to_i
per_page = PER_PAGE_MAX if per_page < 1 || per_page > PER_PAGE_MAX
query = build_index_query
total_count = query.count
key_values =
query
.includes(:user)
.order(:user_id, :key, :created_at)
.offset((page - 1) * per_page)
.limit(per_page + 1)
has_more = key_values.length > per_page
key_values = key_values.first(per_page) if has_more
render json: {
key_values:
ActiveModel::ArraySerializer.new(
key_values,
each_serializer: AiArtifactKeyValueSerializer,
keys_only: params[:keys_only] == "true",
).as_json,
has_more: has_more,
total_count: total_count,
users:
key_values
.map { |kv| kv.user }
.uniq
.map { |u| BasicUserSerializer.new(u, root: nil).as_json },
}
end
def destroy
if params[:key].blank?
render json: { error: "Key parameter is required" }, status: :bad_request
return
end
key_value = @artifact.key_values.find_by(user_id: current_user.id, key: params[:key])
if key_value.nil?
render json: { error: "Key not found" }, status: :not_found
elsif key_value.destroy
head :ok
else
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
end
end
def set
key_value =
@artifact.key_values.find_or_initialize_by(
user: current_user,
key: key_value_params[:key],
)
key_value.assign_attributes(key_value_params.except(:key))
if key_value.save
render json: AiArtifactKeyValueSerializer.new(key_value).as_json
else
render json: { errors: key_value.errors.full_messages }, status: :unprocessable_entity
end
end
private
def key_value_params
params.permit(:key, :value, :public)
end
def index_params
@index_params ||= params.permit(:page, :per_page, :key, :keys_only, :all_users)
end
def build_index_query
query = @artifact.key_values
query =
if current_user&.admin?
query
elsif current_user
query.where("user_id = ? OR public = true", current_user.id)
else
query.where(public: true)
end
query = query.where("key = ?", index_params[:key]) if index_params[:key].present?
if index_params[:all_users].to_s != "true"
if current_user
query = query.where(user_id: current_user.id)
else
query = query.where("1 = 0")
end
end
query
end
def find_artifact
@artifact = AiArtifact.find_by(id: params[:artifact_id])
raise Discourse::NotFound if !@artifact
raise Discourse::NotFound if [email protected]? && guardian.anonymous?
raise Discourse::NotFound if [email protected]? && !guardian.can_see?(@artifact.post)
end
end
end
end
File diff suppressed because it is too large Load Diff
@@ -25,25 +25,24 @@ module DiscourseAi
input = get_text_param! input = get_text_param!
force_default_locale = params[:force_default_locale] || false force_default_locale = params[:force_default_locale] || false
prompt = CompletionPrompt.find_by(id: params[:mode]) raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank?
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled? if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank? raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
prompt.custom_instruction = params[:custom_prompt]
end end
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST
return suggest_thumbnails(input)
end
hijack do hijack do
render json: render json:
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt( DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
prompt, params[:mode],
input, input,
current_user, current_user,
force_default_locale: force_default_locale, force_default_locale: force_default_locale,
custom_prompt: params[:custom_prompt],
), ),
status: 200 status: 200
end end
@@ -60,13 +59,10 @@ module DiscourseAi
input = get_text_param! input = get_text_param!
end end
prompt = CompletionPrompt.enabled_by_name("generate_titles")
raise Discourse::InvalidParameters.new(:mode) if !prompt
hijack do hijack do
render json: render json:
DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt( DiscourseAi::AiHelper::Assistant.new.generate_and_send_prompt(
prompt, DiscourseAi::AiHelper::Assistant::GENERATE_TITLES,
input, input,
current_user, current_user,
), ),
@@ -115,12 +111,12 @@ module DiscourseAi
location = params[:location] location = params[:location]
raise Discourse::InvalidParameters.new(:location) if !location raise Discourse::InvalidParameters.new(:location) if !location
prompt = CompletionPrompt.find_by(id: params[:mode]) raise Discourse::InvalidParameters.new(:mode) if params[:mode].blank?
if params[:mode] == DiscourseAi::AiHelper::Assistant::ILLUSTRATE_POST
return suggest_thumbnails(input)
end
raise Discourse::InvalidParameters.new(:mode) if !prompt || !prompt.enabled? if params[:mode] == DiscourseAi::AiHelper::Assistant::CUSTOM_PROMPT
return suggest_thumbnails(input) if prompt.id == CompletionPrompt::ILLUSTRATE_POST
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank? raise Discourse::InvalidParameters.new(:custom_prompt) if params[:custom_prompt].blank?
end end
@@ -128,15 +124,19 @@ module DiscourseAi
# otherwise we may end up streaming the data to the wrong client # otherwise we may end up streaming the data to the wrong client
raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank? raise Discourse::InvalidParameters.new(:client_id) if params[:client_id].blank?
channel_id = next_channel_id
progress_channel = "discourse_ai_helper/stream_suggestions/#{channel_id}"
if location == "composer" if location == "composer"
Jobs.enqueue( Jobs.enqueue(
:stream_composer_helper, :stream_composer_helper,
user_id: current_user.id, user_id: current_user.id,
text: text, text: text,
prompt: prompt.name, prompt: params[:mode],
custom_prompt: params[:custom_prompt], custom_prompt: params[:custom_prompt],
force_default_locale: params[:force_default_locale] || false, force_default_locale: params[:force_default_locale] || false,
client_id: params[:client_id], client_id: params[:client_id],
progress_channel:,
) )
else else
post_id = get_post_param! post_id = get_post_param!
@@ -149,13 +149,14 @@ module DiscourseAi
post_id: post.id, post_id: post.id,
user_id: current_user.id, user_id: current_user.id,
text: text, text: text,
prompt: prompt.name, prompt: params[:mode],
custom_prompt: params[:custom_prompt], custom_prompt: params[:custom_prompt],
client_id: params[:client_id], client_id: params[:client_id],
progress_channel:,
) )
end end
render json: { success: true }, status: 200 render json: { success: true, progress_channel: }, status: 200
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed
render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"), render_json_error I18n.t("discourse_ai.ai_helper.errors.completion_request_failed"),
status: 502 status: 502
@@ -196,6 +197,18 @@ module DiscourseAi
private private
CHANNEL_ID_KEY = "discourse_ai_helper_next_channel_id"
def next_channel_id
Discourse
.redis
.pipelined do |pipeline|
pipeline.incr(CHANNEL_ID_KEY)
pipeline.expire(CHANNEL_ID_KEY, 1.day)
end
.first
end
def get_text_param! def get_text_param!
params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? } params[:text].tap { |t| raise Discourse::InvalidParameters.new(:text) if t.blank? }
end end
+1 -1
View File
@@ -15,7 +15,7 @@ module ::Jobs
bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new) bot = DiscourseAi::Personas::Bot.as(bot_user, persona: persona.new)
DiscourseAi::AiBot::Playground.new(bot).reply_to(post) DiscourseAi::AiBot::Playground.new(bot).reply_to(post, feature_name: "bot")
rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND rescue DiscourseAi::Personas::Bot::BOT_NOT_FOUND
Rails.logger.warn( Rails.logger.warn(
"Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled", "Bot not found for post #{post.id} - perhaps persona was deleted or bot was disabled",
+60
View File
@@ -0,0 +1,60 @@
# frozen_string_literal: true
module Jobs
class DetectTranslatePost < ::Jobs::Base
cluster_concurrency 1
sidekiq_options retry: false
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if args[:post_id].blank?
post = Post.find_by(id: args[:post_id])
return if post.blank? || post.raw.blank? || post.deleted_at.present? || post.user_id <= 0
topic = post.topic
return if topic.blank?
if SiteSetting.ai_translation_backfill_limit_to_public_content
return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message
else
if topic.archetype == Archetype.private_message &&
!TopicAllowedGroup.exists?(topic_id: topic.id)
return
end
end
# the user may fill locale in manually
if (detected_locale = post.locale).blank?
begin
detected_locale = DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
rescue FinalDestination::SSRFDetector::LookupFailedError
# this job is non-critical
# the backfill job will handle failures
return
end
end
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
locales.each do |locale|
next if locale == detected_locale
next if post.post_localizations.exists?(locale:)
begin
DiscourseAi::Translation::PostLocalizer.localize(post, locale)
rescue FinalDestination::SSRFDetector::LookupFailedError
# do nothing, there are too many sporadic lookup failures
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to translate post #{post.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}",
)
end
end
MessageBus.publish("/topic/#{post.topic_id}", type: :localized, id: post.id)
end
end
end
@@ -0,0 +1,58 @@
# frozen_string_literal: true
module Jobs
class DetectTranslateTopic < ::Jobs::Base
cluster_concurrency 1
sidekiq_options retry: false
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if args[:topic_id].blank?
topic = Topic.find_by(id: args[:topic_id])
if topic.blank? || topic.title.blank? || topic.deleted_at.present? || topic.user_id <= 0
return
end
if SiteSetting.ai_translation_backfill_limit_to_public_content
return if topic.category&.read_restricted? || topic.archetype == Archetype.private_message
else
if topic.archetype == Archetype.private_message &&
!TopicAllowedGroup.exists?(topic_id: topic.id)
return
end
end
if (detected_locale = topic.locale).blank?
begin
detected_locale = DiscourseAi::Translation::TopicLocaleDetector.detect_locale(topic)
rescue FinalDestination::SSRFDetector::LookupFailedError
# this job is non-critical
# the backfill job will handle failures
return
end
end
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
locales.each do |locale|
next if locale == detected_locale
next if topic.topic_localizations.exists?(locale:)
begin
DiscourseAi::Translation::TopicLocalizer.localize(topic, locale)
rescue FinalDestination::SSRFDetector::LookupFailedError
# do nothing, there are too many sporadic lookup failures
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to translate topic #{topic.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}",
)
end
end
MessageBus.publish("/topic/#{topic.id}", type: :localized, id: 1)
end
end
end
@@ -0,0 +1,70 @@
# frozen_string_literal: true
module Jobs
class GenerateInferredConcepts < ::Jobs::Base
sidekiq_options queue: "low"
# Process items to generate new concepts
#
# @param args [Hash] Contains job arguments
# @option args [String] :item_type Required - Type of items to process ('topics' or 'posts')
# @option args [Array<Integer>] :item_ids Required - List of item IDs to process
# @option args [Integer] :batch_size (100) Number of items to process in each batch
# @option args [Boolean] :match_only (false) Only match against existing concepts without generating new ones
def execute(args = {})
return if args[:item_ids].blank? || args[:item_type].blank?
if %w[topics posts].exclude?(args[:item_type])
Rails.logger.error("Invalid item_type for GenerateInferredConcepts: #{args[:item_type]}")
return
end
# Process items in smaller batches to avoid memory issues
batch_size = args[:batch_size] || 100
# Get the list of item IDs
item_ids = args[:item_ids]
match_only = args[:match_only] || false
# Process items in batches
item_ids.each_slice(batch_size) do |batch_item_ids|
process_batch(batch_item_ids, args[:item_type], match_only)
end
end
private
def process_batch(item_ids, item_type, match_only)
klass = item_type.singularize.classify.constantize
items = klass.where(id: item_ids)
manager = DiscourseAi::InferredConcepts::Manager.new
items.each do |item|
begin
process_item(item, item_type, match_only, manager)
rescue => e
Rails.logger.error(
"Error generating concepts from #{item_type.singularize} #{item.id}: #{e.message}\n#{e.backtrace.join("\n")}",
)
end
end
end
def process_item(item, item_type, match_only, manager)
# Use the Manager method that handles both identifying and creating concepts
if match_only
if item_type == "topics"
manager.match_topic_to_concepts(item)
else # posts
manager.match_post_to_concepts(item)
end
else
if item_type == "topics"
manager.generate_concepts_from_topic(item)
else # posts
manager.generate_concepts_from_post(item)
end
end
end
end
end
+56
View File
@@ -0,0 +1,56 @@
# frozen_string_literal: true
module Jobs
class LocalizeCategories < ::Jobs::Base
cluster_concurrency 1
sidekiq_options retry: false
def execute(args)
limit = args[:limit]
raise Discourse::InvalidParameters.new(:limit) if limit.nil?
return if limit <= 0
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
categories = Category.where("locale IS NOT NULL")
if SiteSetting.ai_translation_backfill_limit_to_public_content
categories = categories.where(read_restricted: false)
end
categories = categories.order(:id).limit(limit)
return if categories.empty?
remaining_limit = limit
categories.each do |category|
break if remaining_limit <= 0
existing_locales = CategoryLocalization.where(category_id: category.id).pluck(:locale)
missing_locales = locales - existing_locales - [category.locale]
missing_locales.each do |locale|
break if remaining_limit <= 0
begin
DiscourseAi::Translation::CategoryLocalizer.localize(category, locale)
rescue FinalDestination::SSRFDetector::LookupFailedError
# do nothing, there are too many sporadic lookup failures
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to translate category #{category.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}",
)
ensure
remaining_limit -= 1
end
end
if existing_locales.include?(category.locale)
CategoryLocalization.find_by(category_id: category.id, locale: category.locale).destroy
end
end
end
end
end
+77
View File
@@ -0,0 +1,77 @@
# frozen_string_literal: true
module Jobs
class LocalizePosts < ::Jobs::Base
cluster_concurrency 1
sidekiq_options retry: false
def execute(args)
limit = args[:limit]
raise Discourse::InvalidParameters.new(:limit) if limit.blank? || limit <= 0
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
locales.each do |locale|
posts =
Post
.joins(
"LEFT JOIN post_localizations pl ON pl.post_id = posts.id AND pl.locale = #{ActiveRecord::Base.connection.quote(locale)}",
)
.where(deleted_at: nil)
.where("posts.user_id > 0")
.where.not(raw: [nil, ""])
.where.not(locale: nil)
.where.not(locale: locale)
.where("pl.id IS NULL")
posts = posts.joins(:topic)
if SiteSetting.ai_translation_backfill_limit_to_public_content
# exclude all PMs
# and only include posts from public categories
posts =
posts
.where.not(topics: { archetype: Archetype.private_message })
.where(topics: { category_id: Category.where(read_restricted: false).select(:id) })
else
# all regular topics, and group PMs
posts =
posts.where(
"topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
Archetype.private_message,
)
end
if SiteSetting.ai_translation_backfill_max_age_days > 0
posts =
posts.where(
"posts.created_at > ?",
SiteSetting.ai_translation_backfill_max_age_days.days.ago,
)
end
posts = posts.order(updated_at: :desc).limit(limit)
next if posts.empty?
posts.each do |post|
begin
DiscourseAi::Translation::PostLocalizer.localize(post, locale)
rescue FinalDestination::SSRFDetector::LookupFailedError
# do nothing, there are too many sporadic lookup failures
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to translate post #{post.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}",
)
end
end
DiscourseAi::Translation::VerboseLogger.log("Translated #{posts.size} posts to #{locale}")
end
end
end
end
+74
View File
@@ -0,0 +1,74 @@
# frozen_string_literal: true
module Jobs
class LocalizeTopics < ::Jobs::Base
cluster_concurrency 1
sidekiq_options retry: false
def execute(args)
limit = args[:limit]
raise Discourse::InvalidParameters.new(:limit) if limit.blank? || limit <= 0
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
locales = SiteSetting.content_localization_supported_locales.split("|")
return if locales.blank?
locales.each do |locale|
topics =
Topic
.joins(
"LEFT JOIN topic_localizations tl ON tl.topic_id = topics.id AND tl.locale = #{ActiveRecord::Base.connection.quote(locale)}",
)
.where(deleted_at: nil)
.where("topics.user_id > 0")
.where.not(locale: nil)
.where.not(locale: locale)
.where("tl.id IS NULL")
if SiteSetting.ai_translation_backfill_limit_to_public_content
# exclude all PMs
# and only include posts from public categories
topics =
topics
.where.not(archetype: Archetype.private_message)
.where(category_id: Category.where(read_restricted: false).select(:id))
else
# all regular topics, and group PMs
topics =
topics.where(
"topics.archetype != ? OR topics.id IN (SELECT topic_id FROM topic_allowed_groups)",
Archetype.private_message,
)
end
if SiteSetting.ai_translation_backfill_max_age_days > 0
topics =
topics.where(
"topics.created_at > ?",
SiteSetting.ai_translation_backfill_max_age_days.days.ago,
)
end
topics = topics.order(updated_at: :desc).limit(limit)
next if topics.empty?
topics.each do |topic|
begin
DiscourseAi::Translation::TopicLocalizer.localize(topic, locale)
rescue FinalDestination::SSRFDetector::LookupFailedError
# do nothing, there are too many sporadic lookup failures
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to translate topic #{topic.id} to #{locale}: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}",
)
end
end
DiscourseAi::Translation::VerboseLogger.log("Translated #{topics.size} topics to #{locale}")
end
end
end
end
+5 -7
View File
@@ -9,20 +9,18 @@ module Jobs
return unless user = User.find_by(id: args[:user_id]) return unless user = User.find_by(id: args[:user_id])
return unless args[:text] return unless args[:text]
return unless args[:client_id] return unless args[:client_id]
return unless args[:progress_channel]
prompt = CompletionPrompt.enabled_by_name(args[:prompt]) helper_mode = args[:prompt]
if prompt.id == CompletionPrompt::CUSTOM_PROMPT
prompt.custom_instruction = args[:custom_prompt]
end
DiscourseAi::AiHelper::Assistant.new.stream_prompt( DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt, helper_mode,
args[:text], args[:text],
user, user,
"/discourse-ai/ai-helper/stream_composer_suggestion", args[:progress_channel],
force_default_locale: args[:force_default_locale], force_default_locale: args[:force_default_locale],
client_id: args[:client_id], client_id: args[:client_id],
custom_prompt: args[:custom_prompt],
) )
end end
end end
+11 -11
View File
@@ -8,22 +8,20 @@ module Jobs
return unless post = Post.includes(:topic).find_by(id: args[:post_id]) return unless post = Post.includes(:topic).find_by(id: args[:post_id])
return unless user = User.find_by(id: args[:user_id]) return unless user = User.find_by(id: args[:user_id])
return unless args[:text] return unless args[:text]
return unless args[:progress_channel]
return unless args[:client_id]
topic = post.topic topic = post.topic
reply_to = post.reply_to_post reply_to = post.reply_to_post
return unless user.guardian.can_see?(post) return unless user.guardian.can_see?(post)
prompt = CompletionPrompt.enabled_by_name(args[:prompt]) helper_mode = args[:prompt]
if prompt.id == CompletionPrompt::CUSTOM_PROMPT if helper_mode == DiscourseAi::AiHelper::Assistant::EXPLAIN
prompt.custom_instruction = args[:custom_prompt] input = <<~TEXT.strip
end <term>#{args[:text]}</term>
<context>#{post.raw}</context>
if prompt.name == "explain"
input = <<~TEXT
<term>#{args[:text]}</term>
<context>#{post.raw}</context>
<topic>#{topic.title}</topic> <topic>#{topic.title}</topic>
#{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil} #{reply_to ? "<replyTo>#{reply_to.raw}</replyTo>" : nil}
TEXT TEXT
@@ -32,10 +30,12 @@ module Jobs
end end
DiscourseAi::AiHelper::Assistant.new.stream_prompt( DiscourseAi::AiHelper::Assistant.new.stream_prompt(
prompt, helper_mode,
input, input,
user, user,
"/discourse-ai/ai-helper/stream_suggestion/#{post.id}", args[:progress_channel],
custom_prompt: args[:custom_prompt],
client_id: args[:client_id],
) )
end end
end end
@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Jobs
class CategoriesLocaleDetectionBackfill < ::Jobs::Scheduled
every 1.hour
sidekiq_options retry: false
cluster_concurrency 1
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
limit = SiteSetting.ai_translation_backfill_hourly_rate
return if limit == 0
categories = Category.where(locale: nil)
if SiteSetting.ai_translation_backfill_limit_to_public_content
categories = categories.where(read_restricted: false)
end
categories = categories.limit(limit)
return if categories.empty?
categories.each do |category|
begin
DiscourseAi::Translation::CategoryLocaleDetector.detect_locale(category)
rescue FinalDestination::SSRFDetector::LookupFailedError
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to detect category #{category.id}'s locale: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}",
)
end
end
DiscourseAi::Translation::VerboseLogger.log("Detected #{categories.size} category locales")
end
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
module Jobs
class CategoryLocalizationBackfill < ::Jobs::Scheduled
every 1.hour
cluster_concurrency 1
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if SiteSetting.content_localization_supported_locales.blank?
limit = SiteSetting.ai_translation_backfill_hourly_rate
return if limit == 0
Jobs.enqueue(:localize_categories, limit:)
end
end
end
+100 -90
View File
@@ -18,105 +18,115 @@ module Jobs
) )
end end
rebaked = 0 production_vector = DiscourseAi::Embeddings::Vector.instance
vector = DiscourseAi::Embeddings::Vector.instance if SiteSetting.ai_embeddings_backfill_model.present? &&
vector_def = vector.vdef SiteSetting.ai_embeddings_backfill_model != SiteSetting.ai_embeddings_selected_model
table_name = DiscourseAi::Embeddings::Schema::TOPICS_TABLE backfill_vector =
DiscourseAi::Embeddings::Vector.new(
topics = EmbeddingDefinition.find_by(id: SiteSetting.ai_embeddings_backfill_model),
Topic
.joins(
"LEFT JOIN #{table_name} ON #{table_name}.topic_id = topics.id AND #{table_name}.model_id = #{vector_def.id}",
) )
.where(archetype: Archetype.default) end
.where(deleted_at: nil)
.order("topics.bumped_at DESC")
rebaked += populate_topic_embeddings(vector, topics.limit(limit - rebaked)) topic_work_list = []
topic_work_list << production_vector
topic_work_list << backfill_vector if backfill_vector
return if rebaked >= limit topic_work_list.each do |vector|
rebaked = 0
table_name = DiscourseAi::Embeddings::Schema::TOPICS_TABLE
vector_def = vector.vdef
# Then, we'll try to backfill embeddings for topics that have outdated topics =
# embeddings, be it model or strategy version Topic
relation = topics.where(<<~SQL).limit(limit - rebaked) .joins(
#{table_name}.model_version < #{vector_def.version} "LEFT JOIN #{table_name} ON #{table_name}.topic_id = topics.id AND #{table_name}.model_id = #{vector_def.id}",
OR )
#{table_name}.strategy_version < #{vector_def.strategy_version} .where(archetype: Archetype.default)
SQL .where(deleted_at: nil)
.order("topics.bumped_at DESC")
rebaked += populate_topic_embeddings(vector, relation, force: true) rebaked += populate_topic_embeddings(vector, topics.limit(limit - rebaked))
return if rebaked >= limit next if rebaked >= limit
# Finally, we'll try to backfill embeddings for topics that have outdated # Then, we'll try to backfill embeddings for topics that have outdated
# embeddings due to edits or new replies. Here we only do 10% of the limit # embeddings, be it model or strategy version
relation = relation = topics.where(<<~SQL).limit(limit - rebaked)
topics #{table_name}.model_version < #{vector_def.version}
.where("#{table_name}.updated_at < ?", 6.hours.ago) OR
.where("#{table_name}.updated_at < topics.updated_at") #{table_name}.strategy_version < #{vector_def.strategy_version}
SQL
rebaked += populate_topic_embeddings(vector, relation, force: true)
next if rebaked >= limit
# Finally, we'll try to backfill embeddings for topics that have outdated
# embeddings due to edits or new replies. Here we only do 10% of the limit
relation =
topics
.where("#{table_name}.updated_at < ?", 6.hours.ago)
.where("#{table_name}.updated_at < topics.updated_at")
.limit((limit - rebaked) / 10)
populate_topic_embeddings(vector, relation, force: true)
next unless SiteSetting.ai_embeddings_per_post_enabled
# Now for posts
table_name = DiscourseAi::Embeddings::Schema::POSTS_TABLE
posts_batch_size = 1000
posts =
Post
.joins(
"LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id AND #{table_name}.model_id = #{vector_def.id}",
)
.where(deleted_at: nil)
.where(post_type: Post.types[:regular])
# First, we'll try to backfill embeddings for posts that have none
posts
.where("#{table_name}.post_id IS NULL")
.limit(limit - rebaked)
.pluck(:id)
.each_slice(posts_batch_size) do |batch|
vector.gen_bulk_reprensentations(Post.where(id: batch))
rebaked += batch.length
end
next if rebaked >= limit
# Then, we'll try to backfill embeddings for posts that have outdated
# embeddings, be it model or strategy version
posts
.where(<<~SQL)
#{table_name}.model_version < #{vector_def.version}
OR
#{table_name}.strategy_version < #{vector_def.strategy_version}
SQL
.limit(limit - rebaked)
.pluck(:id)
.each_slice(posts_batch_size) do |batch|
vector.gen_bulk_reprensentations(Post.where(id: batch))
rebaked += batch.length
end
next if rebaked >= limit
# Finally, we'll try to backfill embeddings for posts that have outdated
# embeddings due to edits. Here we only do 10% of the limit
posts
.where("#{table_name}.updated_at < ?", 7.days.ago)
.order("random()")
.limit((limit - rebaked) / 10) .limit((limit - rebaked) / 10)
.pluck(:id)
populate_topic_embeddings(vector, relation, force: true) .each_slice(posts_batch_size) do |batch|
vector.gen_bulk_reprensentations(Post.where(id: batch))
return if rebaked >= limit rebaked += batch.length
end
return unless SiteSetting.ai_embeddings_per_post_enabled end
# Now for posts
table_name = DiscourseAi::Embeddings::Schema::POSTS_TABLE
posts_batch_size = 1000
posts =
Post
.joins(
"LEFT JOIN #{table_name} ON #{table_name}.post_id = posts.id AND #{table_name}.model_id = #{vector_def.id}",
)
.where(deleted_at: nil)
.where(post_type: Post.types[:regular])
# First, we'll try to backfill embeddings for posts that have none
posts
.where("#{table_name}.post_id IS NULL")
.limit(limit - rebaked)
.pluck(:id)
.each_slice(posts_batch_size) do |batch|
vector.gen_bulk_reprensentations(Post.where(id: batch))
rebaked += batch.length
end
return if rebaked >= limit
# Then, we'll try to backfill embeddings for posts that have outdated
# embeddings, be it model or strategy version
posts
.where(<<~SQL)
#{table_name}.model_version < #{vector_def.version}
OR
#{table_name}.strategy_version < #{vector_def.strategy_version}
SQL
.limit(limit - rebaked)
.pluck(:id)
.each_slice(posts_batch_size) do |batch|
vector.gen_bulk_reprensentations(Post.where(id: batch))
rebaked += batch.length
end
return if rebaked >= limit
# Finally, we'll try to backfill embeddings for posts that have outdated
# embeddings due to edits. Here we only do 10% of the limit
posts
.where("#{table_name}.updated_at < ?", 7.days.ago)
.order("random()")
.limit((limit - rebaked) / 10)
.pluck(:id)
.each_slice(posts_batch_size) do |batch|
vector.gen_bulk_reprensentations(Post.where(id: batch))
rebaked += batch.length
end
rebaked
end end
private private
@@ -0,0 +1,87 @@
# frozen_string_literal: true
module Jobs
class GenerateConceptsFromPopularItems < ::Jobs::Scheduled
every 1.day
# This job runs daily and generates new concepts from popular topics and posts
# It selects items based on engagement metrics and generates concepts from their content
def execute(_args)
return unless SiteSetting.inferred_concepts_enabled
process_popular_topics
process_popular_posts
end
private
def process_popular_topics
# Find candidate topics that are popular and don't have concepts yet
manager = DiscourseAi::InferredConcepts::Manager.new
candidates =
manager.find_candidate_topics(
limit: SiteSetting.inferred_concepts_daily_topics_limit || 20,
min_posts: SiteSetting.inferred_concepts_min_posts || 5,
min_likes: SiteSetting.inferred_concepts_min_likes || 10,
min_views: SiteSetting.inferred_concepts_min_views || 100,
created_after: SiteSetting.inferred_concepts_lookback_days.days.ago,
)
return if candidates.blank?
# Process candidate topics - first generate concepts, then match
Jobs.enqueue(
:generate_inferred_concepts,
item_type: "topics",
item_ids: candidates.map(&:id),
batch_size: 10,
)
if SiteSetting.inferred_concepts_background_match
# Schedule a follow-up job to match existing concepts
Jobs.enqueue_in(
1.hour,
:generate_inferred_concepts,
item_type: "topics",
item_ids: candidates.map(&:id),
batch_size: 10,
match_only: true,
)
end
end
def process_popular_posts
# Find candidate posts that are popular and don't have concepts yet
manager = DiscourseAi::InferredConcepts::Manager.new
candidates =
manager.find_candidate_posts(
limit: SiteSetting.inferred_concepts_daily_posts_limit || 30,
min_likes: SiteSetting.inferred_concepts_post_min_likes || 5,
exclude_first_posts: true,
created_after: SiteSetting.inferred_concepts_lookback_days.days.ago,
)
return if candidates.blank?
# Process candidate posts - first generate concepts, then match
Jobs.enqueue(
:generate_inferred_concepts,
item_type: "posts",
item_ids: candidates.map(&:id),
batch_size: 10,
)
if SiteSetting.inferred_concepts_background_match
# Schedule a follow-up job to match against existing concepts
Jobs.enqueue_in(
1.hour,
:generate_inferred_concepts,
item_type: "posts",
item_ids: candidates.map(&:id),
batch_size: 10,
match_only: true,
)
end
end
end
end
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Jobs
class PostLocalizationBackfill < ::Jobs::Scheduled
every 5.minutes
cluster_concurrency 1
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
return if SiteSetting.content_localization_supported_locales.blank?
limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
return if limit == 0
Jobs.enqueue(:localize_posts, limit:)
end
end
end
@@ -0,0 +1,62 @@
# frozen_string_literal: true
module Jobs
class PostsLocaleDetectionBackfill < ::Jobs::Scheduled
every 5.minutes
sidekiq_options retry: false
cluster_concurrency 1
def execute(args)
return if !SiteSetting.discourse_ai_enabled
return if !SiteSetting.ai_translation_enabled
limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
return if limit == 0
posts =
Post
.where(locale: nil)
.where(deleted_at: nil)
.where("posts.user_id > 0")
.where.not(raw: [nil, ""])
if SiteSetting.ai_translation_backfill_limit_to_public_content
posts =
posts
.joins(:topic)
.where(topics: { category_id: Category.where(read_restricted: false).select(:id) })
.where("archetype != ?", Archetype.private_message)
else
posts =
posts.joins(:topic).where(
"topics.archetype != ? OR EXISTS (SELECT 1 FROM topic_allowed_groups WHERE topic_id = topics.id)",
Archetype.private_message,
)
end
if SiteSetting.ai_translation_backfill_max_age_days > 0
posts =
posts.where(
"posts.created_at > ?",
SiteSetting.ai_translation_backfill_max_age_days.days.ago,
)
end
posts = posts.order(updated_at: :desc).limit(limit)
return if posts.empty?
posts.each do |post|
begin
DiscourseAi::Translation::PostLocaleDetector.detect_locale(post)
rescue FinalDestination::SSRFDetector::LookupFailedError
# do nothing, there are too many sporadic lookup failures
rescue => e
DiscourseAi::Translation::VerboseLogger.log(
"Failed to detect post #{post.id}'s locale: #{e.message}\n\n#{e.backtrace[0..3].join("\n")}",
)
end
end
DiscourseAi::Translation::VerboseLogger.log("Detected #{posts.size} post locales")
end
end
end

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