Compare commits
10 Commits
d046810ce8
...
63cf3e713d
Author | SHA1 | Date |
---|---|---|
|
63cf3e713d | |
|
f235c5b18a | |
|
75e23de4ba | |
|
900065b158 | |
|
52931caf3c | |
|
a26c19a80a | |
|
8002962f03 | |
|
12f8a49fc5 | |
|
003134f420 | |
|
d29e4bac3a |
|
@ -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
|
|
@ -4,9 +4,15 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- 'docs/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
- 'docs/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint_and_test:
|
lint_and_test:
|
||||||
|
@ -37,4 +43,4 @@ jobs:
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
||||||
- name: Run Pytest
|
- name: Run Pytest
|
||||||
run: pytest -s
|
run: pytest
|
||||||
|
|
|
@ -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"]
|
|
@ -0,0 +1,8 @@
|
||||||
|
# AI Email Assistant
|
||||||
|
|
||||||
|
[](https://git.itqop.pw/itqop/sl-test/actions)
|
||||||
|
[](https://github.com/psf/black)
|
||||||
|
[](https://github.com/astral-sh/ruff)
|
||||||
|
|
||||||
|
Сервис для генерации персонализированных холодных писем с использованием RAG и LangGraph.
|
||||||
|
|
|
@ -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
|
|
@ -26,8 +26,8 @@ def verify_admin_token(authorization: Optional[str] = Header(None)):
|
||||||
@router.post("/ingest")
|
@router.post("/ingest")
|
||||||
async def trigger_ingest(recreate: bool = False, _: bool = Depends(verify_admin_token)):
|
async def trigger_ingest(recreate: bool = False, _: bool = Depends(verify_admin_token)):
|
||||||
try:
|
try:
|
||||||
data_dir = "articles_konsol_pro"
|
data_dir = "/app/data"
|
||||||
platform_file = "data/platform_overview.md"
|
platform_file = "/app/data/platform_overview.md"
|
||||||
persist_dir = settings.chroma_persist_dir
|
persist_dir = settings.chroma_persist_dir
|
||||||
|
|
||||||
if not os.path.exists(data_dir):
|
if not os.path.exists(data_dir):
|
||||||
|
|
|
@ -43,7 +43,7 @@ def guardrails_node(state: EmailGenerationState) -> EmailGenerationState:
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"locale": lead_model.locale if lead_model else "ru",
|
"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,
|
"used_chunks": email_draft.used_chunks,
|
||||||
"model": llm_output.model if llm_output else "unknown",
|
"model": llm_output.model if llm_output else "unknown",
|
||||||
"tokens_prompt": llm_output.tokens_prompt if llm_output else 0,
|
"tokens_prompt": llm_output.tokens_prompt if llm_output else 0,
|
||||||
|
|
|
@ -39,11 +39,29 @@ def parse_output_node(state: EmailGenerationState) -> EmailGenerationState:
|
||||||
subject = _validate_subject(subject)
|
subject = _validate_subject(subject)
|
||||||
body = _validate_body(body)
|
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(
|
email_draft = EmailDraft(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
body=body,
|
||||||
short_reasoning=short_reasoning,
|
short_reasoning=short_reasoning,
|
||||||
used_chunks=used_chunks if isinstance(used_chunks, list) else [],
|
used_chunks=real_chunk_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
state["email_draft"] = email_draft
|
state["email_draft"] = email_draft
|
||||||
|
|
|
@ -3,7 +3,7 @@ from src.models.email import RankedContext
|
||||||
from src.app.config import settings
|
from src.app.config import settings
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPT = """Вы — AI-ассистент отдела продаж платформы «Консоль.Про». Ваша задача: на основе профиля лида и предоставленного контекста сформировать одно персонализированное холодное письмо (первое касание) на языке лида. Вы пишете кратко, уважительно, без давления. Вы показываете ценность через конкретные результаты клиентов и цифры. Вы не придумываете факты; используете только контекст. Если нет данных — говорите общими преимуществами платформы.
|
SYSTEM_PROMPT = """Вы — AI-ассистент отдела продаж платформы «Консоль.Про». Ваша задача: на основе профиля лида и предоставленного контекста сформировать одно персонализированное холодное письмо (первое касание) на языке лида. Вы пишете кратко, уважительно, без давления. Вы показываете ценность через конкретные результаты клиентов и цифры. Вы не придумываете факты; используете только контекст. Если нет данных или они не релевантны — говорите общими преимуществами платформы.
|
||||||
Формат ответа строго JSON: {"subject": str, "body": str, "short_reasoning": str, "used_chunks": [ids...]}. Без тройных кавычек, без Markdown."""
|
Формат ответа строго JSON: {"subject": str, "body": str, "short_reasoning": str, "used_chunks": [ids...]}. Без тройных кавычек, без Markdown."""
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,18 +18,24 @@ USER_PROMPT_TEMPLATE = """[ПРОФИЛЬ ЛИДА]
|
||||||
|
|
||||||
[СТИЛЬ ПИСЬМА]
|
[СТИЛЬ ПИСЬМА]
|
||||||
- Язык: {locale}
|
- Язык: {locale}
|
||||||
- Тон: деловой, лаконичный, дружелюбный.
|
- Тон: деловой, лаконичный, дружелюбный, с ноткой эмпатии.
|
||||||
- Письмо до ~1600 символов.
|
- Письмо ≤ 1000 символов, раздели на микро‑абзацы по 1–2 предложения, чтобы текст легко читался.
|
||||||
- 1 CTA: предложить 15-мин звонок/демо. Не навязчиво.
|
- Открывай цепляющим вопросом или заявлением («Как насчёт…?», «Представьте, что…»).
|
||||||
- Можно упомянуть, что внедрение платформы занимает ~1 день (если релевантно).
|
- 1 CTA: предложить 15‑минутный звонок/демо. Не навязчиво: «Если вам интересно —», «Буду рад(а) обсудить» и т.п.
|
||||||
|
- Основная боль ↔ основное решение в первых 2–3 абзацах.
|
||||||
|
- Добавь конкретные цифры или проценты («до 100 % собираемости документов», «сокращение времени на 80 %»).
|
||||||
|
- Вставь мини‑соцдоказательство (название клиента + результат (Конкретизировать кейс цифрой ОБЯЗАТЕЛЬНО)) как отдельный абзац или буллет.
|
||||||
|
- Позитивная эмоциональная связь: слова «спокойствие», «уверенность», «контроль».
|
||||||
|
- Предупреди «болевую» эмоцию: «Избегите…», «Снизьте риски…».
|
||||||
|
- Можно упомянуть, что внедрение занимает ~1 день (только если уместно).
|
||||||
|
|
||||||
[ШАБЛОН СТРУКТУРЫ ТЕКСТА]
|
[ШАБЛОН СТРУКТУРЫ ТЕКСТА]
|
||||||
1. Приветствие по имени.
|
1. Приветствие по имени.
|
||||||
2. Короткий хук, отсылающий к индустрии/процессам с исполнителями.
|
2. Короткий усиленный хук‑вопрос, отсылающий к индустрии/процессам с исполнителями.
|
||||||
3. Ценность Консоль.Про + 1–3 релевантные выгоды (цифры, если в контексте).
|
3. Ценность Консоль.Про + 1–3 релевантные выгоды (цифры, если в контексте).
|
||||||
4. Мини-соцдоказательство (кейс, факт).
|
4. Мини-соцдоказательство (кейсы, факты). - ОБЯЗАТЕЛЬНО укажи название клиента и точный численный результат (процент, время, количество).
|
||||||
5. CTA.
|
5. CTA.
|
||||||
6. Подпись: Имя менеджера ({sales_rep_name}) | Консоль.Про.
|
6. Подпись: С уважением, {sales_rep_name}
|
||||||
|
|
||||||
Напомню: ответ только JSON."""
|
Напомню: ответ только JSON."""
|
||||||
|
|
||||||
|
|
|
@ -70,12 +70,7 @@ class RetrievalService:
|
||||||
|
|
||||||
text_query = " ".join(search_terms)
|
text_query = " ".join(search_terms)
|
||||||
|
|
||||||
metadata_filters = {}
|
metadata_filters = None
|
||||||
if lead_features.industry_tag != "other":
|
|
||||||
metadata_filters["$or"] = [
|
|
||||||
{"industry": {"$contains": lead_features.industry_tag}},
|
|
||||||
{"roles_relevant": {"$contains": lead_features.role_category}},
|
|
||||||
]
|
|
||||||
|
|
||||||
return RetrievalQuery(
|
return RetrievalQuery(
|
||||||
text_query=text_query,
|
text_query=text_query,
|
||||||
|
@ -155,9 +150,12 @@ class RetrievalService:
|
||||||
if chunk.metadata.get("metrics"):
|
if chunk.metadata.get("metrics"):
|
||||||
metrics = chunk.metadata["metrics"]
|
metrics = chunk.metadata["metrics"]
|
||||||
metrics_parts = []
|
metrics_parts = []
|
||||||
for key, value in metrics.items():
|
if isinstance(metrics, dict):
|
||||||
if isinstance(value, (int, float)):
|
for key, value in metrics.items():
|
||||||
metrics_parts.append(f"{key}: {value}")
|
if isinstance(value, (int, float)):
|
||||||
|
metrics_parts.append(f"{key}: {value}")
|
||||||
|
elif isinstance(metrics, str):
|
||||||
|
metrics_parts.append(metrics)
|
||||||
if metrics_parts:
|
if metrics_parts:
|
||||||
metrics_info = f" ({', '.join(metrics_parts)})"
|
metrics_info = f" ({', '.join(metrics_parts)})"
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,6 @@ def test_health_endpoint(mock_embedding_service, mock_chroma_store):
|
||||||
response = client.get("/healthz")
|
response = client.get("/healthz")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
print("Health check response:", data)
|
|
||||||
assert "status" in data
|
assert "status" in data
|
||||||
assert data["status"] == "healthy"
|
assert data["status"] == "healthy"
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue