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.
234 lines
8.1 KiB
Ruby
234 lines
8.1 KiB
Ruby
# 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
|