Forum taxonomy injected into instructions block + source-derived CTA category

This commit is contained in:
grgmro 2026-09-17 11:18:32 +02:00
parent a24ae90ecb
commit 216f4391d9
2 changed files with 92 additions and 27 deletions

View file

@ -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,20 +246,52 @@ module DepiedencapAiCitations
def self.instructions_block(question)
pack = retrieve_pack(question)
return legacy_instructions_block(pack[:sources]) if pack[:zone] == :legacy
return NO_EVIDENCE_BLOCK.strip if pack[:sources].blank? || pack[:zone] == :empty
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]}]#{excerpt}"
"#{i + 1}. title: #{s[:title]} [via:#{s[:via]}]#{cat}#{excerpt}"
end
tpl = pack[:zone] == :weak ? WEAK_EVIDENCE_TEMPLATE : EVIDENCE_TEMPLATE
format(tpl, lines: lines.join("\n")).strip
end
"#{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)
retrieve_pack(question)[:sources]
rescue StandardError => e
@ -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}",

View file

@ -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,31 +285,52 @@ 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",
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
end
end
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
{
parent: "Les souliers",
child: "Le prêt à chausser",
label: "Les souliers → #{cat.name}",
path: category_href(cat),
cta: "si aucun de ces fils ne colle à *ton* cas, ouvre un sujet (usage, contraintes, ce que tu cherches).",
}
end
cat = find_named_category(spec[:parent], spec[:child])
return nil unless cat
# 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?
{
label: "#{spec[:parent]} → #{cat.name}",
path: category_href(cat),
cta: spec[:cta],
}
Category.find_by(id: ids.tally.max_by { |_id, n| n }&.first)
end
def self.find_named_category(parent_name, child_name)