Compare commits

...

10 Commits

Author SHA1 Message Date
itqop 63cf3e713d Add Readme
CI / lint_and_test (push) Successful in 2m31s Details
2025-07-19 17:38:37 +03:00
itqop f235c5b18a Add rules for CI 2025-07-19 17:36:58 +03:00
itqop 75e23de4ba Reformat code 2025-07-19 17:35:09 +03:00
itqop 900065b158 Add docker deploy 2025-07-19 17:29:16 +03:00
itqop 52931caf3c Fix typo 2025-07-19 17:25:13 +03:00
itqop a26c19a80a Fix chunk finder 2025-07-19 16:56:09 +03:00
itqop 8002962f03 Improve prompt 2025-07-19 16:55:48 +03:00
itqop 12f8a49fc5 Remove -s flag from pytest CI 2025-07-19 04:09:58 +03:00
itqop 003134f420 Useless 2025-07-19 03:53:31 +03:00
itqop d29e4bac3a Try to fix CI 2025-07-19 03:48:02 +03:00
12 changed files with 218 additions and 23 deletions

71
.dockerignore Normal file
View File

@ -0,0 +1,71 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
.ruff_cache
.DS_Store
.vscode
.idea
*.swp
*.swo
*~
*.md
!README.md
.env
.env.local
.env.*.local
.env.production
.env.staging
storage/chroma/*
!storage/chroma/.gitkeep
articles_konsol_pro/*
!articles_konsol_pro/.gitkeep
scripts/
src/tests/
src/__pycache__/
*.tmp
*.temp
*.bak
*.backup
Dockerfile*
docker-compose*
.dockerignore
.github
.gitlab-ci.yml
.travis.yml
.circleci
.gitea
logs/
*.log
.venv/
venv/
env/
Техническое задание.md

View File

@ -4,9 +4,15 @@ on:
push:
branches:
- main
paths-ignore:
- 'README.md'
- 'docs/**'
pull_request:
branches:
- main
paths-ignore:
- 'README.md'
- 'docs/**'
jobs:
lint_and_test:
@ -37,4 +43,4 @@ jobs:
run: ruff check .
- name: Run Pytest
run: pytest -s
run: pytest

38
Dockerfile Normal file
View File

@ -0,0 +1,38 @@
FROM python:3.10-slim
LABEL maintainer="AI Email Assistant Team"
LABEL version="1.0.0"
LABEL description="AI Email Assistant with RAG and LangGraph"
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
g++ \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/storage/chroma /app/data/articles_konsol_pro && \
chown -R appuser:appuser /app
USER appuser
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:8000/healthz || exit 1
CMD ["uvicorn", "src.app.main:app", "--host", "0.0.0.0", "--port", "8000"]

8
README.md Normal file
View File

@ -0,0 +1,8 @@
# AI Email Assistant
[![CI](https://img.shields.io/badge/CI-passing-brightgreen)](https://git.itqop.pw/itqop/sl-test/actions)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
Сервис для генерации персонализированных холодных писем с использованием RAG и LangGraph.

51
docker-compose.yml Normal file
View File

@ -0,0 +1,51 @@
version: '3.8'
services:
ai-email-assistant:
build:
context: .
dockerfile: Dockerfile
container_name: ai-email-assistant
ports:
- "8000:8000"
environment:
- LLM_PROVIDER=openai
- LLM_MODEL=gpt-4o
- EMBEDDING_MODEL=text-embedding-3-large
- OPENAI_API_KEY=${OPENAI_API_KEY}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- CHROMA_PERSIST_DIR=/app/storage/chroma
- TOP_K=30
- TOP_N_CONTEXT=6
- MAX_TOKENS_COMPLETION=1024
- LANGGRAPH_TRACING=false
- SERVICE_LANG=ru
- SALES_REP_NAME=Команда Консоль.Про
- CHUNK_SIZE=500
- CHUNK_OVERLAP=100
- API_SECRET_KEY=${API_SECRET_KEY:-secret}
- PYTHONPATH=/app
- PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
volumes:
- ./storage/chroma:/app/storage/chroma:rw
- ./data:/app/data:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
restart: unless-stopped
deploy:
resources:
limits:
memory: 2G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=100m

View File

@ -26,8 +26,8 @@ def verify_admin_token(authorization: Optional[str] = Header(None)):
@router.post("/ingest")
async def trigger_ingest(recreate: bool = False, _: bool = Depends(verify_admin_token)):
try:
data_dir = "articles_konsol_pro"
platform_file = "data/platform_overview.md"
data_dir = "/app/data"
platform_file = "/app/data/platform_overview.md"
persist_dir = settings.chroma_persist_dir
if not os.path.exists(data_dir):

View File

@ -43,7 +43,7 @@ def guardrails_node(state: EmailGenerationState) -> EmailGenerationState:
meta = {
"locale": lead_model.locale if lead_model else "ru",
"lead_normalized": lead_model.dict() if lead_model else {},
"lead_normalized": lead_model.model_dump() if lead_model else {},
"used_chunks": email_draft.used_chunks,
"model": llm_output.model if llm_output else "unknown",
"tokens_prompt": llm_output.tokens_prompt if llm_output else 0,

View File

@ -39,11 +39,29 @@ def parse_output_node(state: EmailGenerationState) -> EmailGenerationState:
subject = _validate_subject(subject)
body = _validate_body(body)
ranked_context = state.get("ranked_context")
real_chunk_ids = []
if isinstance(used_chunks, list) and ranked_context:
for chunk_idx in used_chunks:
try:
idx = int(chunk_idx) - 1
if 0 <= idx < len(ranked_context.chunks):
chunk = ranked_context.chunks[idx]
chunk_info = f"{chunk.parent_doc_id}#{chunk.chunk_id}"
real_chunk_ids.append(chunk_info)
else:
real_chunk_ids.append(str(chunk_idx))
except (ValueError, TypeError):
real_chunk_ids.append(str(chunk_idx))
else:
real_chunk_ids = []
email_draft = EmailDraft(
subject=subject,
body=body,
short_reasoning=short_reasoning,
used_chunks=used_chunks if isinstance(used_chunks, list) else [],
used_chunks=real_chunk_ids,
)
state["email_draft"] = email_draft

View File

@ -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."""
@ -18,18 +18,24 @@ USER_PROMPT_TEMPLATE = """[ПРОФИЛЬ ЛИДА]
[СТИЛЬ ПИСЬМА]
- Язык: {locale}
- Тон: деловой, лаконичный, дружелюбный.
- Письмо до ~1600 символов.
- 1 CTA: предложить 15-мин звонок/демо. Не навязчиво.
- Можно упомянуть, что внедрение платформы занимает ~1 день (если релевантно).
- Тон: деловой, лаконичный, дружелюбный, с ноткой эмпатии.
- Письмо 1000символов, раздели на микроабзацы по 12 предложения, чтобы текст легко читался.
- Открывай цепляющим вопросом или заявлением («Как насчёт?», «Представьте, что»).
- 1 CTA: предложить 15минутный звонок/демо. Не навязчиво: «Если вам интересно », «Буду рад(а) обсудить» и т.п.
- Основная боль основное решение в первых 23 абзацах.
- Добавь конкретные цифры или проценты («до 100% собираемости документов», «сокращение времени на 80%»).
- Вставь минисоцдоказательство (название клиента + результат (Конкретизировать кейс цифрой ОБЯЗАТЕЛЬНО)) как отдельный абзац или буллет.
- Позитивная эмоциональная связь: слова «спокойствие», «уверенность», «контроль».
- Предупреди «болевую» эмоцию: «Избегите», «Снизьте риски».
- Можно упомянуть, что внедрение занимает ~1день (только если уместно).
[ШАБЛОН СТРУКТУРЫ ТЕКСТА]
1. Приветствие по имени.
2. Короткий хук, отсылающий к индустрии/процессам с исполнителями.
2. Короткий усиленный хуквопрос, отсылающий к индустрии/процессам с исполнителями.
3. Ценность Консоль.Про + 13 релевантные выгоды (цифры, если в контексте).
4. Мини-соцдоказательство (кейс, факт).
4. Мини-соцдоказательство (кейсы, факты). - ОБЯЗАТЕЛЬНО укажи название клиента и точный численный результат (процент, время, количество).
5. CTA.
6. Подпись: Имя менеджера ({sales_rep_name}) | Консоль.Про.
6. Подпись: С уважением, {sales_rep_name}
Напомню: ответ только JSON."""

View File

@ -70,12 +70,7 @@ class RetrievalService:
text_query = " ".join(search_terms)
metadata_filters = {}
if lead_features.industry_tag != "other":
metadata_filters["$or"] = [
{"industry": {"$contains": lead_features.industry_tag}},
{"roles_relevant": {"$contains": lead_features.role_category}},
]
metadata_filters = None
return RetrievalQuery(
text_query=text_query,
@ -155,9 +150,12 @@ class RetrievalService:
if chunk.metadata.get("metrics"):
metrics = chunk.metadata["metrics"]
metrics_parts = []
for key, value in metrics.items():
if isinstance(value, (int, float)):
metrics_parts.append(f"{key}: {value}")
if isinstance(metrics, dict):
for key, value in metrics.items():
if isinstance(value, (int, float)):
metrics_parts.append(f"{key}: {value}")
elif isinstance(metrics, str):
metrics_parts.append(metrics)
if metrics_parts:
metrics_info = f" ({', '.join(metrics_parts)})"

View File

@ -20,7 +20,6 @@ def test_health_endpoint(mock_embedding_service, mock_chroma_store):
response = client.get("/healthz")
assert response.status_code == 200
data = response.json()
print("Health check response:", data)
assert "status" in data
assert data["status"] == "healthy"

View File