diff --git a/src/graph/nodes/context_rank.py b/src/graph/nodes/context_rank.py index 2a88d75..8ef554d 100644 --- a/src/graph/nodes/context_rank.py +++ b/src/graph/nodes/context_rank.py @@ -27,7 +27,12 @@ def context_rank_node(state: EmailGenerationState) -> EmailGenerationState: ranked_context = retrieval_service.build_context(ranked_chunks) + context_quality_score = retrieval_service.calculate_context_quality_score( + ranked_chunks + ) + state["ranked_context"] = ranked_context + state["context_quality_score"] = context_quality_score return state except Exception as e: diff --git a/src/graph/nodes/guardrails.py b/src/graph/nodes/guardrails.py index 64ded1d..f1f944f 100644 --- a/src/graph/nodes/guardrails.py +++ b/src/graph/nodes/guardrails.py @@ -40,6 +40,7 @@ def guardrails_node(state: EmailGenerationState) -> EmailGenerationState: llm_output = state.get("llm_output") lead_model = state.get("lead_model") ranked_context = state.get("ranked_context") + context_quality_score = state.get("context_quality_score") meta = { "locale": lead_model.locale if lead_model else "ru", @@ -50,6 +51,9 @@ def guardrails_node(state: EmailGenerationState) -> EmailGenerationState: "tokens_completion": llm_output.tokens_completion if llm_output else 0, "guardrails_violations": len(violations), "context_chunks_used": len(ranked_context.chunks) if ranked_context else 0, + "context_quality_score": ( + context_quality_score if context_quality_score is not None else 0.0 + ), } email_clean = EmailDraftClean( diff --git a/src/graph/state.py b/src/graph/state.py index 78eaa9f..0f2ac65 100644 --- a/src/graph/state.py +++ b/src/graph/state.py @@ -20,6 +20,7 @@ class EmailGenerationState(TypedDict): retrieval_query: Optional[RetrievalQuery] retrieved_chunks: Optional[List[DocChunk]] ranked_context: Optional[RankedContext] + context_quality_score: Optional[float] prompt_payload: Optional[PromptPayload] llm_output: Optional[LLMRawOutput] email_draft: Optional[EmailDraft] diff --git a/src/ingest/loader.py b/src/ingest/loader.py index 22fa248..6e2ca50 100644 --- a/src/ingest/loader.py +++ b/src/ingest/loader.py @@ -254,7 +254,7 @@ class MarkdownLoader: r"hello@konsol\.pro", r"\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}", r"125047.*?дом \d+", - r"\[Разработка — SKDO\]\(\)", + r"\[Разработка - SKDO\]\(\)", r"\[Подключиться к Консоли\]\(\)", r"\[Кейсы наших клиентов\]\(\)", r"\[Делимся экспертизой\]\(\)", diff --git a/src/services/prompt_templates.py b/src/services/prompt_templates.py index f807130..f44fa46 100644 --- a/src/services/prompt_templates.py +++ b/src/services/prompt_templates.py @@ -3,7 +3,7 @@ from src.models.email import RankedContext from src.app.config import settings -SYSTEM_PROMPT = """Вы — AI-ассистент отдела продаж платформы «Консоль.Про». Ваша задача: на основе профиля лида и предоставленного контекста сформировать одно персонализированное холодное письмо (первое касание) на языке лида. Вы пишете кратко, уважительно, без давления. Вы показываете ценность через конкретные результаты клиентов и цифры. Вы не придумываете факты; используете только контекст. Если нет данных или они не релевантны — говорите общими преимуществами платформы. +SYSTEM_PROMPT = """Вы - AI-ассистент отдела продаж платформы «Консоль.Про». Ваша задача: на основе профиля лида и предоставленного контекста сформировать одно персонализированное холодное письмо (первое касание) на языке лида. Вы пишете кратко, уважительно, без давления. Вы показываете ценность через конкретные результаты клиентов и цифры. Вы не придумываете факты; используете только контекст. Если нет данных или они не релевантны - говорите общими преимуществами платформы. Формат ответа строго JSON: {"subject": str, "body": str, "short_reasoning": str, "used_chunks": [ids...]}. Без тройных кавычек, без Markdown.""" @@ -21,7 +21,7 @@ USER_PROMPT_TEMPLATE = """[ПРОФИЛЬ ЛИДА] - Тон: деловой, лаконичный, дружелюбный, с ноткой эмпатии. - Письмо ≤ 1000 символов, раздели на микро‑абзацы по 1–2 предложения, чтобы текст легко читался. - Открывай цепляющим вопросом или заявлением («Как насчёт…?», «Представьте, что…»). -- 1 CTA: предложить 15‑минутный звонок/демо. Не навязчиво: «Если вам интересно —», «Буду рад(а) обсудить» и т.п. +- 1 CTA: предложить 15‑минутный звонок/демо. Не навязчиво: «Если вам интересно -», «Буду рад(а) обсудить» и т.п. - Основная боль ↔ основное решение в первых 2–3 абзацах. - Добавь конкретные цифры или проценты («до 100 % собираемости документов», «сокращение времени на 80 %»). - Вставь мини‑соцдоказательство (название клиента + результат (Конкретизировать кейс цифрой ОБЯЗАТЕЛЬНО)) как отдельный абзац или буллет. @@ -79,10 +79,10 @@ class PromptBuilder: hooks = { "marketing_agency": "В маркетинговых агентствах часто сложно быстро подключать десятки подрядчиков для проектов", "logistics": "В логистических компаниях управление множественными исполнителями требует особого внимания к документообороту", - "software": "В IT-компаниях работа с фрилансерами и ИП — важная часть команды разработки", + "software": "В IT-компаниях работа с фрилансерами и ИП - важная часть команды разработки", "retail": "В розничной торговле сезонные пики требуют быстрого масштабирования команды исполнителей", - "consulting": "В консалтинговых компаниях привлечение экспертов под проекты — критичный процесс", - "construction": "В строительстве координация подрядчиков и документооборот — ключевые вызовы", + "consulting": "В консалтинговых компаниях привлечение экспертов под проекты - критичный процесс", + "construction": "В строительстве координация подрядчиков и документооборот - ключевые вызовы", "other": "При работе с внешними исполнителями всегда актуальны вопросы автоматизации процессов", } return hooks.get(industry_tag, hooks["other"]) diff --git a/src/services/retrieval.py b/src/services/retrieval.py index 24d20aa..0112382 100644 --- a/src/services/retrieval.py +++ b/src/services/retrieval.py @@ -166,3 +166,39 @@ class RetrievalService: total_tokens=total_tokens, summary_bullets=summary_bullets, ) + + def calculate_context_quality_score(self, chunks: List[DocChunk]) -> float: + if not chunks: + return 0.0 + + scores = [ + chunk.similarity_score + for chunk in chunks + if chunk.similarity_score is not None + ] + + if not scores: + return 0.0 + + mean_similarity = sum(scores) / len(scores) + + high_quality_ratio = sum(1 for score in scores if score > 0.7) / len(scores) + + if len(scores) > 1: + variance = sum((score - mean_similarity) ** 2 for score in scores) / len( + scores + ) + consistency_score = max(0, 1 - variance * 2) + else: + consistency_score = 1.0 + + quantity_factor = min(1.0, len(chunks) / 3.0) + + quality_score = ( + 0.40 * mean_similarity + + 0.25 * high_quality_ratio + + 0.20 * consistency_score + + 0.15 * quantity_factor + ) + + return max(0.0, min(1.0, quality_score))