From 216f4391d976b47c918c3f0c4b5e4169e9f21f9d Mon Sep 17 00:00:00 2001 From: grgmro Date: Thu, 17 Sep 2026 11:18:32 +0200 Subject: [PATCH] Forum taxonomy injected into instructions block + source-derived CTA category --- lib/retrieval.rb | 66 ++++++++++++++++++++++++++++++++++++++++-------- lib/sanitizer.rb | 53 ++++++++++++++++++++++++++------------ 2 files changed, 92 insertions(+), 27 deletions(-) diff --git a/lib/retrieval.rb b/lib/retrieval.rb index 6ffe757..99d47e0 100644 --- a/lib/retrieval.rb +++ b/lib/retrieval.rb @@ -171,7 +171,8 @@ module DepiedencapAiCitations advice) that are not literally in the excerpts. 2) You may cautiously point to the closest thread(s) with [[n]] — framed as "peut-être en rapport", never as a real answer. - 3) Invite the user to post in a fitting category or rephrase. + 3) Invite the user to post in a fitting category — name the most relevant + one from the « Forum categories » list below. 4) All citation and role-limit rules from the main prompt still apply. ### Evidence @@ -182,7 +183,8 @@ module DepiedencapAiCitations ## STABLE FORUM EVIDENCE No sufficiently relevant forum threads were retrieved. Reply briefly in the user's language that you lack solid forum evidence for this question. - Invite them to post in a fitting category. Do NOT invent threads, brands, or /t/ URLs. + Invite them to post in the most fitting category — name it from the + « Forum categories » list below. Do NOT invent threads, brands, or /t/ URLs. Do NOT write ### Sources. Do NOT call tools. TXT @@ -244,18 +246,50 @@ module DepiedencapAiCitations def self.instructions_block(question) pack = retrieve_pack(question) - return legacy_instructions_block(pack[:sources]) if pack[:zone] == :legacy + block = + if pack[:zone] == :legacy + legacy_instructions_block(pack[:sources]) + elsif pack[:sources].blank? || pack[:zone] == :empty + NO_EVIDENCE_BLOCK.strip + else + lines = + pack[:sources].each_with_index.map do |s, i| + cat = s[:category].present? ? " [cat:#{s[:category]}]" : "" + excerpt = s[:excerpt].present? ? "\n excerpt: #{s[:excerpt]}" : "" + "#{i + 1}. title: #{s[:title]} [via:#{s[:via]}]#{cat}#{excerpt}" + end - return NO_EVIDENCE_BLOCK.strip if pack[:sources].blank? || pack[:zone] == :empty - - lines = - pack[:sources].each_with_index.map do |s, i| - excerpt = s[:excerpt].present? ? "\n excerpt: #{s[:excerpt]}" : "" - "#{i + 1}. title: #{s[:title]} [via:#{s[:via]}]#{excerpt}" + tpl = pack[:zone] == :weak ? WEAK_EVIDENCE_TEMPLATE : EVIDENCE_TEMPLATE + format(tpl, lines: lines.join("\n")).strip end - tpl = pack[:zone] == :weak ? WEAK_EVIDENCE_TEMPLATE : EVIDENCE_TEMPLATE - format(tpl, lines: lines.join("\n")).strip + "#{block}\n\n#{forum_taxonomy_block}".strip + end + + # Taxonomie publique du forum, injectée dans le bloc d'instructions : le LLM + # peut nommer la bonne (sous-)catégorie quand il invite à poster, même sans + # evidence probante. Cachée 12 h — les catégories bougent rarement. + def self.forum_taxonomy_block + Discourse.cache.fetch("dpec_taxonomy", expires_in: 12.hours) do + cats = + Category + .where(read_restricted: false) + .where.not(id: SiteSetting.uncategorized_category_id) + .order(:position) + .to_a + by_parent = cats.group_by(&:parent_category_id) + + lines = ["## Forum categories (public — where discussions live)"] + by_parent[nil].to_a.each do |parent| + pdesc = parent.description.to_s.gsub(/<[^>]+>/, " ").squish + lines << "#{parent.name}#{pdesc.present? ? " — #{pdesc}" : ""}" + by_parent[parent.id].to_a.each do |child| + cdesc = child.description.to_s.gsub(/<[^>]+>/, " ").squish + lines << " - #{child.name}#{cdesc.present? ? " — #{cdesc}" : ""}" + end + end + lines.join("\n") + end end def self.for_question(question) @@ -445,6 +479,8 @@ module DepiedencapAiCitations title: h[:title], url: "/t/#{h[:slug]}/#{h[:topic_id]}/#{h[:post_number]}", excerpt: h[:raw_excerpt], + category: h[:cat_label], + category_id: h[:category_id], via: "posts", } end @@ -460,10 +496,14 @@ module DepiedencapAiCitations def self.post_candidates(qvec, vdef) sql = <<~SQL SELECT e.post_id, p.topic_id, p.post_number, t.title, t.slug, p.raw, + t.category_id, + c.name AS cat_name, pc.name AS parent_cat_name, e.embeddings <=> '#{qvec}' AS dist FROM ai_posts_embeddings e JOIN posts p ON p.id = e.post_id JOIN topics t ON t.id = p.topic_id + LEFT JOIN categories c ON c.id = t.category_id + LEFT JOIN categories pc ON pc.id = c.parent_category_id WHERE e.model_id = #{vdef.id.to_i} AND e.strategy_id = #{vdef.strategy_id.to_i} AND p.deleted_at IS NULL AND p.post_type = 1 AND t.deleted_at IS NULL AND t.archetype = 'regular' @@ -480,12 +520,16 @@ module DepiedencapAiCitations ActiveRecord::Base.connection.execute(sql).to_a.map do |row| raw = row["raw"].to_s.gsub(/\s+/, " ").strip[0, 300] + parent = row["parent_cat_name"].to_s.presence + cat = row["cat_name"].to_s.presence { post_id: row["post_id"].to_i, topic_id: row["topic_id"].to_i, post_number: row["post_number"].to_i, slug: row["slug"], title: row["title"], + category_id: row["category_id"].to_i, + cat_label: [parent, cat].compact.join(" → "), dist: row["dist"].to_f, raw_excerpt: raw, ce_text: "#{row["title"]}. #{raw}", diff --git a/lib/sanitizer.rb b/lib/sanitizer.rb index 610605f..802203b 100644 --- a/lib/sanitizer.rb +++ b/lib/sanitizer.rb @@ -194,7 +194,7 @@ module DepiedencapAiCitations cta_sources(sources).each do |s| lines << "- [#{s[:title]}](#{canonical(s[:url])}) : #{cta_invitation(s)}" end - cat = suggested_category(question) + cat = suggested_category(question, sources) lines << "- [#{cat[:label]}](#{cat[:path]}) : #{cat[:cta]}" if cat lines.join("\n") end @@ -285,33 +285,54 @@ module DepiedencapAiCitations end end - def self.suggested_category(question) + # Catégorie suggérée, par priorité : + # 1) intention de la question (mots-clés — ex. « entretien » → sous-cat + # dédiée, même si les sources citées vivent ailleurs) ; + # 2) catégorie dominante des sources citées (la géographie du forum décide + # quand la question n'a pas de mot-clé connu — ex. whisky → Comptoir) ; + # 3) défaut historique « Le prêt à chausser ». + def self.suggested_category(question, sources = nil) q = question.to_s - spec = - if q.match?(/entretien|cirage|patine|glac|tr[ée]pointe/i) - { - parent: "Les souliers", - child: "Entretien, réparation, glaçage et patine", + if q.match?(/entretien|cirage|patine|glac|tr[ée]pointe/i) + cat = find_named_category("Les souliers", "Entretien, réparation, glaçage et patine") + if cat + return { + label: "Les souliers → #{cat.name}", + path: category_href(cat), cta: "ouvre un sujet avec photos / produits si ton cas n’y est pas.", } - else - { - parent: "Les souliers", - child: "Le prêt à chausser", - cta: "si aucun de ces fils ne colle à *ton* cas, ouvre un sujet (usage, contraintes, ce que tu cherches).", - } end + end - cat = find_named_category(spec[:parent], spec[:child]) + if (cat = dominant_source_category(sources)) + label = + cat.parent_category ? "#{cat.parent_category.name} → #{cat.name}" : cat.name + return { + label: label, + path: category_href(cat), + cta: "les discussions citées vivent dans cette catégorie — ouvre-y ton sujet si aucune ne colle à *ton* cas.", + } + end + + cat = find_named_category("Les souliers", "Le prêt à chausser") return nil unless cat { - label: "#{spec[:parent]} → #{cat.name}", + label: "Les souliers → #{cat.name}", path: category_href(cat), - cta: spec[:cta], + cta: "si aucun de ces fils ne colle à *ton* cas, ouvre un sujet (usage, contraintes, ce que tu cherches).", } end + # Catégorie la plus fréquente parmi les sources citées. Nil si les sources + # n'ont pas de catégorie (canal legacy) ou sont toutes sans catégorie. + def self.dominant_source_category(sources) + ids = Array(sources).map { |s| s[:category_id] }.compact + return nil if ids.empty? + + Category.find_by(id: ids.tally.max_by { |_id, n| n }&.first) + end + def self.find_named_category(parent_name, child_name) parent = Category.find_by(name: parent_name) return nil unless parent