depiedencap-rag/lib/playground.rb
grgmro 39cffbc1bb depiedencap-rag — export public initial
Plugin Discourse RAG (grain post, rerank cross-encoder, citations
whitelistées, gate déterministe, réécriture de requête multi-tour)
+ documentation du chantier (design, éval, sécurité, pipeline).

Export sanitisé depuis le monorepo privé via scripts/export_rag_public.py
(aucun endpoint interne, IP, compte démo ou chemin d'infrastructure).
WIP : agent en test sur groupe restreint, pas encore public sur le forum.
2026-09-17 09:26:45 +02:00

234 lines
8.1 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# frozen_string_literal: true
module DepiedencapAiCitations
# Signature alignée sur Post#revise (updated_by, changes, opts).
# Pendant le revise Discourse AI de fin de stream : enregistrer le brut (avec [[n]])
# et sauver une version SANS marqueurs — le client ne voit pas l’étape [[2]] [[5]].
module PostReviseDeferCites
def revise(updated_by, changes = {}, opts = {}, **kwargs)
opts = opts.merge(kwargs) if kwargs.any?
if Thread.current[:dpec_ai_defer_cites] && changes.is_a?(Hash) && changes[:raw].present?
Thread.current[:dpec_ai_raw_full] = changes[:raw].to_s
changes =
changes.merge(raw: Sanitizer.strip_stream_markers(changes[:raw]))
end
super(updated_by, changes, opts)
end
end
module PlaygroundHook
def reply_to(post, custom_instructions: nil, **kwargs, &blk)
stabilize = stabilize?(post)
# Gate d'entrée : réponse fixe sans appel LLM (pipeline v2, mesuré).
if stabilize && SiteSetting.depiedencap_ai_citations_input_gate &&
DepiedencapAiCitations::Retrieval.gate_match(post.raw)
publish_gate_decline!(post)
return nil
end
if stabilize && DepiedencapAiCitations::WarmupStatus.unavailable?
publish_unavailable!(post)
return nil
end
if stabilize
block = DepiedencapAiCitations::Retrieval.instructions_block_for_post(post)
custom_instructions = [custom_instructions, block].compact.join("\n\n") if block.present?
Thread.current[:dpec_ai_defer_cites] = true
Thread.current[:dpec_ai_raw_full] = nil
end
result = nil
error = nil
begin
result = super(post, custom_instructions: custom_instructions, **kwargs, &blk)
rescue StandardError => e
error = e
Rails.logger.warn("DepiedencapAiCitations::PlaygroundHook: #{e.class} #{e.message}")
ensure
Thread.current[:dpec_ai_defer_cites] = false
end
apply_sanitize!(post) if stabilize && error.nil?
Thread.current[:dpec_ai_raw_full] = nil
if error
if stabilize &&
(
DepiedencapAiCitations::Unavailable.connection_error?(error) ||
bot_post_is_unavailable?(post)
)
publish_unavailable!(post)
return nil
end
raise error
end
result
end
# Même requête HTTP que le post utilisateur : pas de job Sidekiq, pas de stream.
def schedule_bot_reply(post, authorization_user: post.user)
if stabilize?(post)
if SiteSetting.depiedencap_ai_citations_input_gate &&
DepiedencapAiCitations::Retrieval.gate_match(post.raw)
publish_gate_decline!(post)
return
end
if DepiedencapAiCitations::WarmupStatus.unavailable?
publish_unavailable!(post)
return
end
end
super
end
# Stream = autre thread. On masque [[n]] d’après le post, pas Thread.current.
def publish_update(bot_reply_post, payload:, user_ids:, group_ids:)
if strip_stream_for?(bot_reply_post) && payload
payload = payload.dup
if payload[:raw].present?
@dpec_full_reply = payload[:raw]
payload[:raw] = Sanitizer.strip_stream_markers(payload[:raw])
end
if payload[:cooked].present?
payload[:cooked] = Sanitizer.strip_cooked_markers(payload[:cooked])
end
end
super
end
def publish_final_update(reply_post, user_ids:, group_ids:)
# Discourse cook le brut LLM complet (avec [[2]] [[5]]) puis publie done:true
# AVANT le revise. C’est l’étape intermédiaire à sauter. Le PostStreamer
# jette le dernier chunk (finish skip_callback: false) : ne pas recuire
# @dpec_full_reply, trop court.
if strip_stream_for?(reply_post) && reply_post.cooked.present?
reply_post.cooked = Sanitizer.strip_cooked_markers(reply_post.cooked)
end
super
end
private
def bot_post_is_unavailable?(user_post)
guide_id = DepiedencapAiCitations::Sanitizer.guide_user_id
return false if guide_id.blank?
bot_post = user_post.topic.posts.where(user_id: guide_id).order(post_number: :desc).first
return false if bot_post.blank?
DepiedencapAiCitations::Unavailable.reply?(bot_post.raw) ||
DepiedencapAiCitations::Unavailable.reply?(@dpec_full_reply)
end
def publish_unavailable!(user_post)
publish_canned_reply!(user_post, DepiedencapAiCitations::Unavailable::MESSAGE)
end
# Réponse fixe du gate d'entrée (hors-rôle / dangereux) — pas de LLM.
def publish_gate_decline!(user_post)
publish_canned_reply!(user_post, DepiedencapAiCitations::Retrieval::GATE_DECLINE_MSG)
end
def publish_canned_reply!(user_post, msg)
guide_id = DepiedencapAiCitations::Sanitizer.guide_user_id
return if guide_id.blank?
bot_user = User.find_by(id: guide_id)
return if bot_user.blank?
bot_post = user_post.topic.posts.where(user_id: guide_id).order(post_number: :desc).first
reuse_streaming_post =
bot_post &&
bot_post.post_number.to_i > user_post.post_number.to_i &&
(
bot_post.raw.blank? ||
DepiedencapAiCitations::Unavailable.reply?(bot_post.raw)
)
if reuse_streaming_post
bot_post.revise(
bot_user,
{ raw: msg },
skip_validations: true,
skip_revision: true,
)
else
bot_post =
PostCreator.create!(
bot_user,
topic_id: user_post.topic_id,
raw: msg,
skip_validations: true,
skip_guardian: true,
)
end
bot_post.custom_fields["depiedencap_ai_citations"] = "t"
bot_post.save_custom_fields
bot_post.reload
land_unavailable_stream!(bot_post)
rescue StandardError => e
Rails.logger.warn("DepiedencapAiCitations publish_unavailable: #{e.class} #{e.message}")
end
# Discourse AI a déjà envoyé done:true avec le reply_error TCP.
# On republie le cooked humoristique pour arrêter le point clignotant.
def land_unavailable_stream!(bot_post)
return if bot_post.blank? || !bot_post.topic&.private_message?
topic = bot_post.topic
@published_final_update = false
publish_final_update(
bot_post,
user_ids: topic.allowed_users.pluck(:id),
group_ids: topic.allowed_groups.pluck(:id),
)
rescue StandardError => e
Rails.logger.warn("DepiedencapAiCitations land_unavailable_stream: #{e.class} #{e.message}")
end
def apply_sanitize!(user_post)
guide_id = DepiedencapAiCitations::Sanitizer.guide_user_id
return if guide_id.blank?
bot_post = user_post.topic.posts.where(user_id: guide_id).order(post_number: :desc).first
return if bot_post.blank?
bot_post.reload
# Le revise Playground a le texte COMPLET ; le stream (0,5 s) peut être en retard.
full = Thread.current[:dpec_ai_raw_full].presence || @dpec_full_reply
bot_post.raw = full if full.present?
if DepiedencapAiCitations::Sanitizer.unavailable_reply?(bot_post.raw)
DepiedencapAiCitations::Sanitizer.keep_unavailable_only!(bot_post)
land_unavailable_stream!(bot_post.reload)
return
end
bot_post.custom_fields.delete("depiedencap_ai_citations")
bot_post.save_custom_fields
DepiedencapAiCitations::Sanitizer.sanitize!(bot_post)
rescue StandardError => e
Rails.logger.warn("DepiedencapAiCitations apply_sanitize: #{e.class} #{e.message}")
end
def strip_stream_for?(reply_post)
return false unless SiteSetting.depiedencap_ai_citations_enabled
DepiedencapAiCitations::Sanitizer.guide_bot_post?(reply_post) &&
reply_post.topic&.private_message?
end
def stabilize?(post)
return false unless SiteSetting.depiedencap_ai_citations_enabled
return false if post.blank? || !post.topic&.private_message?
return false if post.user_id.to_i <= 0
guide_id = DepiedencapAiCitations::Sanitizer.guide_user_id
guide_id.present? && post.topic.topic_allowed_users.exists?(user_id: guide_id)
end
end
end