Coverage for baibu/chat/tagging.py: 100%
107 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Optional tagging of conversations by topic, language and intent.
3Off unless ``CHAT_TAGGING_ENABLED``. Topics are defined by the deployment in
4the admin (*Chat → Topics*); intents come from ``CHAT_TAGGING_INTENTS``.
5A Celery beat task tags conversations that have been idle for
6``CHAT_TAGGING_IDLE_MINUTES`` and changed since they were last tagged, but
7only conversations whose consent tier allows evaluation.
9The tagger asks a language model through LiteLLM (``CHAT_TAGGING_MODEL``,
10or the chat's default model) using the template
11``chat/tagging_prompt.txt``. With the ``mock`` model it matches topic
12labels and slugs as keywords instead, so it works without a model.
13"""
15import json
16import logging
17import re
18from dataclasses import dataclass
19from dataclasses import field
20from datetime import timedelta
22from django.conf import settings
23from django.db import transaction
24from django.template.loader import render_to_string
25from django.utils import timezone
27from baibu.users.consent import TIER_RANK
29from . import llm
30from .models import Conversation
31from .models import ConversationTag
32from .models import Message
33from .models import Topic
35logger = logging.getLogger(__name__)
37_JSON_BLOCK = re.compile(r"\{.*\}", re.DOTALL)
40class TaggingError(Exception):
41 pass
44@dataclass
45class TagResult:
46 topics: list[str] = field(default_factory=list)
47 language: str = ""
48 intent: str = ""
51def transcript(conversation: Conversation) -> str:
52 lines = [
53 f"{'User' if m.role == Message.Role.USER else 'Assistant'}: {m.content}"
54 for m in conversation.messages.exclude(content="")[: settings.CHAT_TAGGING_MAX_MESSAGES]
55 ]
56 return "\n".join(lines)[: settings.CHAT_TAGGING_MAX_CHARACTERS]
59def model_config() -> llm.ModelConfig:
60 from .services import default_variant
62 if settings.CHAT_TAGGING_MODEL:
63 return llm.ModelConfig(model=settings.CHAT_TAGGING_MODEL)
64 return llm.ModelConfig.from_variant(default_variant())
67def keyword_tags(text: str, topics: list[Topic], language: str) -> TagResult:
68 """The mock tagger: a topic matches if its slug or label appears as words."""
69 lowered = text.lower()
70 found = []
71 for topic in topics:
72 for term in {topic.slug.replace("-", " "), topic.label.lower()}:
73 if re.search(rf"\b{re.escape(term.lower())}\b", lowered):
74 found.append(topic.slug)
75 break
76 intent = "question" if "?" in text else "conversation"
77 return TagResult(topics=found, language=language, intent=intent if intent in intents() else "")
80def intents() -> list[str]:
81 return list(settings.CHAT_TAGGING_INTENTS)
84def model_tags(text: str, topics: list[Topic], config: llm.ModelConfig) -> TagResult:
85 prompt = render_to_string(
86 "chat/tagging_prompt.txt",
87 {"topics": topics, "intents": intents(), "max_topics": settings.CHAT_TAGGING_MAX_TOPICS},
88 )
89 try:
90 completion = llm.complete(config, [{"role": "system", "content": prompt}, {"role": "user", "content": text}])
91 except llm.ModelError as exc:
92 raise TaggingError(str(exc)) from exc
93 match = _JSON_BLOCK.search(completion.content or "")
94 try:
95 data = json.loads(match.group(0) if match else completion.content)
96 except (json.JSONDecodeError, TypeError) as exc:
97 msg = "Tagger answer is not JSON."
98 raise TaggingError(msg) from exc
99 if not isinstance(data, dict):
100 msg = "Tagger answer is not an object."
101 raise TaggingError(msg)
102 known = {topic.slug for topic in topics}
103 raw_topics = data.get("topics") if isinstance(data.get("topics"), list) else []
104 chosen = [slug for slug in dict.fromkeys(str(t) for t in raw_topics) if slug in known]
105 intent = str(data.get("intent") or "")
106 return TagResult(
107 topics=chosen[: settings.CHAT_TAGGING_MAX_TOPICS],
108 language=str(data.get("language") or "")[:24],
109 intent=intent if intent in intents() else "",
110 )
113def tag_conversation(conversation: Conversation) -> ConversationTag:
114 topics = list(Topic.objects.filter(active=True))
115 activity = conversation.last_activity_at
116 config = model_config()
117 tag, _created = ConversationTag.objects.get_or_create(
118 conversation=conversation, defaults={"tagged_activity_at": activity}
119 )
120 text = transcript(conversation)
121 try:
122 if config.model == "mock":
123 result = keyword_tags(text, topics, conversation.language_code)
124 tagger = "keywords"
125 else:
126 result = model_tags(text, topics, config)
127 tagger = config.model
128 error = ""
129 except TaggingError as exc:
130 result, tagger, error = TagResult(), config.model, str(exc)[:500]
131 logger.warning("Could not tag conversation %s: %s", conversation.pk, error)
132 with transaction.atomic():
133 tag.language = result.language
134 tag.intent = result.intent
135 tag.tagger = tagger[:255]
136 tag.error = error
137 tag.tagged_activity_at = activity
138 tag.save()
139 tag.topics.set([t for t in topics if t.slug in result.topics])
140 return tag
143def eligible_tiers() -> list[str]:
144 minimum = TIER_RANK[settings.CHAT_TAGGING_MIN_TIER]
145 return [tier for tier, rank in TIER_RANK.items() if rank >= minimum]
148def due_conversations(now=None):
149 """Idle conversations with consent, never tagged or changed since tagging."""
150 from django.db.models import F
151 from django.db.models import Q
153 now = now or timezone.now()
154 idle_since = now - timedelta(minutes=settings.CHAT_TAGGING_IDLE_MINUTES)
155 return (
156 Conversation.objects.filter(last_activity_at__lte=idle_since, consent_tier__in=eligible_tiers())
157 .filter(messages__isnull=False)
158 .filter(Q(tag__isnull=True) | Q(tag__tagged_activity_at__lt=F("last_activity_at")))
159 .distinct()
160 .order_by("last_activity_at")
161 )
164def tag_due(now=None) -> int:
165 if not settings.CHAT_TAGGING_ENABLED:
166 return 0
167 count = 0
168 for conversation in due_conversations(now)[: settings.CHAT_TAGGING_BATCH_SIZE]:
169 tag_conversation(conversation)
170 count += 1
171 return count