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. advice) that are not literally in the excerpts.
2) You may cautiously point to the closest thread(s) with [[n]] — framed as 2) You may cautiously point to the closest thread(s) with [[n]] — framed as
"peut-être en rapport", never as a real answer. "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. 4) All citation and role-limit rules from the main prompt still apply.
### Evidence ### Evidence
@ -182,7 +183,8 @@ module DepiedencapAiCitations
## STABLE FORUM EVIDENCE ## STABLE FORUM EVIDENCE
No sufficiently relevant forum threads were retrieved. No sufficiently relevant forum threads were retrieved.
Reply briefly in the user's language that you lack solid forum evidence for this question. 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. Do NOT write ### Sources. Do NOT call tools.
TXT TXT
@ -244,20 +246,52 @@ module DepiedencapAiCitations
def self.instructions_block(question) def self.instructions_block(question)
pack = retrieve_pack(question) pack = retrieve_pack(question)
return legacy_instructions_block(pack[:sources]) if pack[:zone] == :legacy block =
if pack[:zone] == :legacy
return NO_EVIDENCE_BLOCK.strip if pack[:sources].blank? || pack[:zone] == :empty legacy_instructions_block(pack[:sources])
elsif pack[:sources].blank? || pack[:zone] == :empty
NO_EVIDENCE_BLOCK.strip
else
lines = lines =
pack[:sources].each_with_index.map do |s, i| pack[:sources].each_with_index.map do |s, i|
cat = s[:category].present? ? " [cat:#{s[:category]}]" : ""
excerpt = s[:excerpt].present? ? "\n excerpt: #{s[:excerpt]}" : "" 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 end
tpl = pack[:zone] == :weak ? WEAK_EVIDENCE_TEMPLATE : EVIDENCE_TEMPLATE tpl = pack[:zone] == :weak ? WEAK_EVIDENCE_TEMPLATE : EVIDENCE_TEMPLATE
format(tpl, lines: lines.join("\n")).strip format(tpl, lines: lines.join("\n")).strip
end 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) def self.for_question(question)
retrieve_pack(question)[:sources] retrieve_pack(question)[:sources]
rescue StandardError => e rescue StandardError => e
@ -445,6 +479,8 @@ module DepiedencapAiCitations
title: h[:title], title: h[:title],
url: "/t/#{h[:slug]}/#{h[:topic_id]}/#{h[:post_number]}", url: "/t/#{h[:slug]}/#{h[:topic_id]}/#{h[:post_number]}",
excerpt: h[:raw_excerpt], excerpt: h[:raw_excerpt],
category: h[:cat_label],
category_id: h[:category_id],
via: "posts", via: "posts",
} }
end end
@ -460,10 +496,14 @@ module DepiedencapAiCitations
def self.post_candidates(qvec, vdef) def self.post_candidates(qvec, vdef)
sql = <<~SQL sql = <<~SQL
SELECT e.post_id, p.topic_id, p.post_number, t.title, t.slug, p.raw, 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 e.embeddings <=> '#{qvec}' AS dist
FROM ai_posts_embeddings e FROM ai_posts_embeddings e
JOIN posts p ON p.id = e.post_id JOIN posts p ON p.id = e.post_id
JOIN topics t ON t.id = p.topic_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} 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 p.deleted_at IS NULL AND p.post_type = 1
AND t.deleted_at IS NULL AND t.archetype = 'regular' 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| ActiveRecord::Base.connection.execute(sql).to_a.map do |row|
raw = row["raw"].to_s.gsub(/\s+/, " ").strip[0, 300] 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, post_id: row["post_id"].to_i,
topic_id: row["topic_id"].to_i, topic_id: row["topic_id"].to_i,
post_number: row["post_number"].to_i, post_number: row["post_number"].to_i,
slug: row["slug"], slug: row["slug"],
title: row["title"], title: row["title"],
category_id: row["category_id"].to_i,
cat_label: [parent, cat].compact.join(" → "),
dist: row["dist"].to_f, dist: row["dist"].to_f,
raw_excerpt: raw, raw_excerpt: raw,
ce_text: "#{row["title"]}. #{raw}", ce_text: "#{row["title"]}. #{raw}",

View file

@ -194,7 +194,7 @@ module DepiedencapAiCitations
cta_sources(sources).each do |s| cta_sources(sources).each do |s|
lines << "- [#{s[:title]}](#{canonical(s[:url])}) : #{cta_invitation(s)}" lines << "- [#{s[:title]}](#{canonical(s[:url])}) : #{cta_invitation(s)}"
end end
cat = suggested_category(question) cat = suggested_category(question, sources)
lines << "- [#{cat[:label]}](#{cat[:path]}) : #{cat[:cta]}" if cat lines << "- [#{cat[:label]}](#{cat[:path]}) : #{cat[:cta]}" if cat
lines.join("\n") lines.join("\n")
end end
@ -285,31 +285,52 @@ module DepiedencapAiCitations
end end
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 q = question.to_s
spec =
if q.match?(/entretien|cirage|patine|glac|tr[ée]pointe/i) if q.match?(/entretien|cirage|patine|glac|tr[ée]pointe/i)
{ cat = find_named_category("Les souliers", "Entretien, réparation, glaçage et patine")
parent: "Les souliers", if cat
child: "Entretien, réparation, glaçage et patine", return {
label: "Les souliers → #{cat.name}",
path: category_href(cat),
cta: "ouvre un sujet avec photos / produits si ton cas n’y est pas.", 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", label: "Les souliers → #{cat.name}",
child: "Le prêt à chausser", path: category_href(cat),
cta: "si aucun de ces fils ne colle à *ton* cas, ouvre un sujet (usage, contraintes, ce que tu cherches).", 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]) # Catégorie la plus fréquente parmi les sources citées. Nil si les sources
return nil unless cat # 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)
label: "#{spec[:parent]} → #{cat.name}",
path: category_href(cat),
cta: spec[:cta],
}
end end
def self.find_named_category(parent_name, child_name) def self.find_named_category(parent_name, child_name)