alexorlov commited on
Commit
6d2b0f9
·
verified ·
1 Parent(s): 95f9dc5

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies (ffmpeg REQUIRED for audio conversion!)
6
+ RUN apt-get update && apt-get install -y \
7
+ ffmpeg \
8
+ libsndfile1 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Force rebuild: v1 (change this comment to invalidate Docker cache)
12
+ COPY requirements.txt .
13
+
14
+ # Install Python dependencies
15
+ RUN pip install --no-cache-dir -r requirements.txt
16
+
17
+ # Pre-download Whisper model during build (faster startup)
18
+ RUN python -c "from transformers import pipeline; pipeline('automatic-speech-recognition', model='openai/whisper-small')"
19
+
20
+ # Copy application code
21
+ COPY ./app /app/app
22
+
23
+ # HuggingFace Spaces uses port 7860
24
+ EXPOSE 7860
25
+
26
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,32 @@
1
  ---
2
- title: Checklist Agent
3
- emoji: 🦀
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AI Checklist Agent
3
+ emoji: 📋
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
+ license: mit
9
  ---
10
 
11
+ # AI Checklist Agent Backend
12
+
13
+ API сервис для AI агента заполнения чеклиста созвона с клиентом.
14
+
15
+ ## Features
16
+
17
+ - Голосовой ввод с транскрипцией через Whisper
18
+ - 3 раунда по 3 вопроса (адаптивные)
19
+ - Генерация структурированного чеклиста
20
+ - Экспорт в Markdown
21
+
22
+ ## API Endpoints
23
+
24
+ - `POST /api/session/start` - Начать новую сессию
25
+ - `POST /api/session/transcribe` - Транскрибировать аудио
26
+ - `POST /api/session/{id}/submit` - Отправить ответы
27
+ - `GET /api/session/{id}/results` - Получить результаты
28
+ - `GET /api/session/{id}/download` - Скачать MD файл
29
+
30
+ ## Environment Variables
31
+
32
+ - `ANTHROPIC_API_KEY` - API ключ Anthropic для Claude
app/__init__.py ADDED
File without changes
app/agent/__init__.py ADDED
File without changes
app/agent/graph.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langgraph.graph import StateGraph, END
2
+ from app.agent.state import AgentState
3
+ from app.agent.nodes import (
4
+ generate_initial_questions,
5
+ process_answers,
6
+ analyze_round,
7
+ generate_checklist,
8
+ check_round_complete
9
+ )
10
+
11
+
12
+ def create_checklist_agent() -> StateGraph:
13
+ """Создает LangGraph для чеклист-агента"""
14
+
15
+ # Создаем граф с состоянием AgentState
16
+ workflow = StateGraph(AgentState)
17
+
18
+ # Добавляем ноды
19
+ workflow.add_node("generate_initial_questions", generate_initial_questions)
20
+ workflow.add_node("process_answers", process_answers)
21
+ workflow.add_node("analyze_round", analyze_round)
22
+ workflow.add_node("generate_checklist", generate_checklist)
23
+
24
+ # Устанавливаем начальную точку
25
+ workflow.set_entry_point("generate_initial_questions")
26
+
27
+ # Добавляем переходы
28
+ # После генерации вопросов - ждем ответы (END чтобы вернуть контроль)
29
+ workflow.add_edge("generate_initial_questions", END)
30
+
31
+ # После обработки ответов - анализируем раунд
32
+ workflow.add_edge("process_answers", "analyze_round")
33
+
34
+ # После анализа - либо ждем новые ответы, либо генерируем чеклист
35
+ workflow.add_conditional_edges(
36
+ "analyze_round",
37
+ check_round_complete,
38
+ {
39
+ "wait_for_answers": END, # Ждем следующие ответы
40
+ "generate_checklist": "generate_checklist" # Генерируем чеклист
41
+ }
42
+ )
43
+
44
+ # После генерации чеклиста - конец
45
+ workflow.add_edge("generate_checklist", END)
46
+
47
+ return workflow.compile()
48
+
49
+
50
+ # Создаем экземпляр агента
51
+ checklist_agent = create_checklist_agent()
app/agent/nodes.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any
2
+ from app.agent.state import AgentState
3
+ from app.services.llm import get_llm_service
4
+ from app.services.file_generator import get_file_generator
5
+ from app.models.question import Question, Answer
6
+ from app.models.checklist import ChecklistItem
7
+
8
+
9
+ def generate_initial_questions(state: AgentState) -> Dict[str, Any]:
10
+ """Генерирует первые 3 вопроса для начала интервью"""
11
+ llm = get_llm_service()
12
+ questions_data = llm.generate_initial_questions()
13
+
14
+ questions = [
15
+ Question(id=q["id"], text=q["text"])
16
+ for q in questions_data
17
+ ]
18
+
19
+ return {
20
+ "current_questions": questions,
21
+ "current_round": 1,
22
+ "waiting_for_answers": True
23
+ }
24
+
25
+
26
+ def process_answers(state: AgentState) -> Dict[str, Any]:
27
+ """Обрабатывает полученные ответы и создает Answer объекты"""
28
+ transcripts = state.get("pending_transcripts", [])
29
+ current_questions = state.get("current_questions", [])
30
+ current_round = state.get("current_round", 1)
31
+ all_answers = list(state.get("all_answers", []))
32
+
33
+ # Создаем Answer объекты из транскриптов
34
+ for i, transcript in enumerate(transcripts):
35
+ if i < len(current_questions):
36
+ answer = Answer(
37
+ question_id=current_questions[i].id,
38
+ question_text=current_questions[i].text,
39
+ audio_transcript=transcript,
40
+ round_number=current_round
41
+ )
42
+ all_answers.append(answer)
43
+
44
+ return {
45
+ "all_answers": all_answers,
46
+ "pending_transcripts": [],
47
+ "waiting_for_answers": False
48
+ }
49
+
50
+
51
+ def analyze_round(state: AgentState) -> Dict[str, Any]:
52
+ """Анализирует ответы раунда и генерирует следующие вопросы или завершает"""
53
+ llm = get_llm_service()
54
+
55
+ current_round = state.get("current_round", 1)
56
+ all_answers = state.get("all_answers", [])
57
+ round_summaries = list(state.get("round_summaries", []))
58
+
59
+ # Анализируем раунд
60
+ result = llm.analyze_round_and_generate_questions(
61
+ round_number=current_round,
62
+ all_answers=all_answers,
63
+ round_summaries=round_summaries
64
+ )
65
+
66
+ # Добавляем саммари раунда
67
+ round_summaries.append(result.get("round_summary", ""))
68
+
69
+ # Если это не последний раунд - генерируем следующие вопросы
70
+ if current_round < state.get("max_rounds", 3):
71
+ questions_data = result.get("questions", [])
72
+ questions = [
73
+ Question(id=q["id"], text=q["text"])
74
+ for q in questions_data
75
+ ]
76
+
77
+ return {
78
+ "current_questions": questions,
79
+ "current_round": current_round + 1,
80
+ "round_summaries": round_summaries,
81
+ "waiting_for_answers": True,
82
+ "is_complete": False
83
+ }
84
+ else:
85
+ # Последний раунд - готовимся к генерации чеклиста
86
+ return {
87
+ "round_summaries": round_summaries,
88
+ "waiting_for_answers": False,
89
+ "is_complete": False
90
+ }
91
+
92
+
93
+ def generate_checklist(state: AgentState) -> Dict[str, Any]:
94
+ """Генерирует финальный чеклист"""
95
+ llm = get_llm_service()
96
+ file_gen = get_file_generator()
97
+
98
+ all_answers = state.get("all_answers", [])
99
+ round_summaries = state.get("round_summaries", [])
100
+ session_id = state.get("session_id", "unknown")
101
+
102
+ # Генерируем чеклист
103
+ result = llm.generate_checklist(all_answers, round_summaries)
104
+
105
+ checklist_items = [
106
+ ChecklistItem(**item)
107
+ for item in result.get("checklist", [])
108
+ ]
109
+
110
+ # Генерируем Markdown
111
+ markdown = file_gen.generate_markdown(
112
+ session_id=session_id,
113
+ checklist=checklist_items,
114
+ round_summaries=round_summaries
115
+ )
116
+
117
+ return {
118
+ "checklist_items": checklist_items,
119
+ "markdown_content": markdown,
120
+ "is_complete": True
121
+ }
122
+
123
+
124
+ def check_round_complete(state: AgentState) -> str:
125
+ """Проверяет, нужно ли продолжать или завершать"""
126
+ current_round = state.get("current_round", 1)
127
+ max_rounds = state.get("max_rounds", 3)
128
+
129
+ if current_round >= max_rounds:
130
+ return "generate_checklist"
131
+ else:
132
+ return "wait_for_answers"
app/agent/prompts.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SYSTEM_PROMPT = """Ты - AI ассистент, который помогает заполнить чеклист созвона с клиентом.
2
+ Твоя задача - задавать вопросы и анализировать ответы, чтобы собрать всю необходимую информацию о проекте клиента.
3
+
4
+ Основные темы для выяснения:
5
+ 1. Общая информация о проекте (название, описание, контакты)
6
+ 2. Цели и задачи (что хотят достичь, ключевые метрики)
7
+ 3. Сроки и бюджет (дедлайны, финансовые ограничения)
8
+ 4. Технические требования (интеграции, платформы, технологии)
9
+ 5. Дополнительная информация (риски, особенности, пожелания)
10
+
11
+ Правила:
12
+ - Задавай открытые вопросы
13
+ - Адаптируй следующие вопросы на основе полученных ответов
14
+ - Будь вежливым и профессиональным
15
+ - Все общение ведется на русском языке
16
+ """
17
+
18
+ INITIAL_QUESTIONS_PROMPT = """Сгенерируй 3 начальных вопроса для клиента.
19
+
20
+ Вопросы должны быть направлены на выяснение:
21
+ 1. Общей информации о проекте
22
+ 2. Целей и ожидаемых результатов
23
+ 3. Текущей ситуации и контекста
24
+
25
+ Формат ответа - JSON:
26
+ {
27
+ "questions": [
28
+ {"id": "q1", "text": "..."},
29
+ {"id": "q2", "text": "..."},
30
+ {"id": "q3", "text": "..."}
31
+ ]
32
+ }
33
+ """
34
+
35
+ ANALYZE_ROUND_PROMPT = """Проанализируй ответы клиента и:
36
+ 1. Создай краткое саммари раунда (2-3 предложения)
37
+ 2. Определи, какая информация уже получена
38
+ 3. Определи, что еще нужно уточнить
39
+ 4. Сгенерируй 3 уточняющих вопроса для следующего раунда
40
+
41
+ Фокусируйся на недостающей информации и углубляй понимание проекта.
42
+ """
43
+
44
+ GENERATE_CHECKLIST_PROMPT = """На основе всех полученных ответов создай структурированный чеклист.
45
+
46
+ Используй категории:
47
+ - Общая информация
48
+ - Цели и задачи
49
+ - Сроки и бюджет
50
+ - Технические требования
51
+ - Дополнительные заметки
52
+
53
+ Для каждого пункта укажи статус:
54
+ - confirmed - информация подтверждена
55
+ - needs_clarification - требует уточнения
56
+ - not_discussed - не обсуждалось
57
+ """
app/agent/state.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, List, Optional
2
+ from app.models.question import Question, Answer
3
+ from app.models.checklist import ChecklistItem
4
+
5
+
6
+ class AgentState(TypedDict):
7
+ # Session info
8
+ session_id: str
9
+
10
+ # Round tracking
11
+ current_round: int # 1, 2, or 3
12
+ max_rounds: int # 3
13
+
14
+ # Questions & Answers
15
+ current_questions: List[Question]
16
+ all_answers: List[Answer]
17
+ pending_transcripts: List[str]
18
+
19
+ # Analysis
20
+ round_summaries: List[str]
21
+
22
+ # Final output
23
+ checklist_items: List[ChecklistItem]
24
+ markdown_content: str
25
+
26
+ # Control flow
27
+ is_complete: bool
28
+ waiting_for_answers: bool
app/config.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+ from functools import lru_cache
3
+
4
+
5
+ class Settings(BaseSettings):
6
+ anthropic_api_key: str = ""
7
+ environment: str = "development"
8
+ max_audio_duration_seconds: int = 120
9
+ whisper_model: str = "openai/whisper-small"
10
+ allowed_origins: str = "*"
11
+
12
+ class Config:
13
+ env_file = ".env"
14
+ extra = "ignore"
15
+
16
+
17
+ @lru_cache()
18
+ def get_settings() -> Settings:
19
+ return Settings()
app/main.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+
5
+ from app.config import get_settings
6
+ from app.routers import health, session
7
+ from app.services.transcription import get_transcription_service
8
+
9
+
10
+ @asynccontextmanager
11
+ async def lifespan(app: FastAPI):
12
+ # Загружаем Whisper модель при старте
13
+ print("Загрузка Whisper модели...")
14
+ service = get_transcription_service()
15
+ service._get_pipeline()
16
+ print("Whisper модель загружена!")
17
+ yield
18
+ # Cleanup при остановке
19
+ print("Остановка сервиса...")
20
+
21
+
22
+ app = FastAPI(
23
+ title="AI Checklist Agent API",
24
+ description="API для AI агента заполнения чеклиста созвона с клиентом",
25
+ version="1.0.0",
26
+ lifespan=lifespan
27
+ )
28
+
29
+ # Настройка CORS
30
+ settings = get_settings()
31
+ origins = settings.allowed_origins.split(",") if settings.allowed_origins != "*" else ["*"]
32
+
33
+ app.add_middleware(
34
+ CORSMiddleware,
35
+ allow_origins=origins,
36
+ allow_credentials=True,
37
+ allow_methods=["*"],
38
+ allow_headers=["*"],
39
+ )
40
+
41
+ # Подключаем роутеры
42
+ app.include_router(health.router)
43
+ app.include_router(session.router)
44
+
45
+
46
+ @app.get("/")
47
+ async def root():
48
+ return {
49
+ "message": "AI Checklist Agent API",
50
+ "docs": "/docs",
51
+ "health": "/health"
52
+ }
app/models/__init__.py ADDED
File without changes
app/models/checklist.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List
3
+
4
+
5
+ class ChecklistItem(BaseModel):
6
+ category: str
7
+ item: str
8
+ status: str # "confirmed" | "needs_clarification" | "not_discussed"
9
+ notes: Optional[str] = None
10
+
11
+
12
+ class ChecklistResponse(BaseModel):
13
+ session_id: str
14
+ checklist: List[ChecklistItem]
15
+ markdown: str
app/models/question.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+
4
+
5
+ class Question(BaseModel):
6
+ id: str
7
+ text: str
8
+
9
+
10
+ class Answer(BaseModel):
11
+ question_id: str
12
+ question_text: str
13
+ audio_transcript: str
14
+ round_number: int
15
+
16
+
17
+ class QuestionResponse(BaseModel):
18
+ id: str
19
+ text: str
app/models/session.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import List, Optional
3
+ from .question import Question, Answer
4
+ from .checklist import ChecklistItem
5
+
6
+
7
+ class SessionStartResponse(BaseModel):
8
+ session_id: str
9
+ round: int
10
+ questions: List[Question]
11
+
12
+
13
+ class TranscribeResponse(BaseModel):
14
+ transcript: str
15
+
16
+
17
+ class SubmitResponse(BaseModel):
18
+ round: int
19
+ is_complete: bool
20
+ questions: Optional[List[Question]] = None
21
+ round_summary: Optional[str] = None
22
+ checklist_preview: Optional[str] = None
23
+ transcripts: Optional[List[str]] = None
24
+
25
+
26
+ class SessionResultsResponse(BaseModel):
27
+ session_id: str
28
+ checklist: List[ChecklistItem]
29
+ markdown: str
app/routers/__init__.py ADDED
File without changes
app/routers/health.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ router = APIRouter(tags=["health"])
4
+
5
+
6
+ @router.get("/health")
7
+ async def health_check():
8
+ """Health check для HuggingFace Spaces"""
9
+ return {
10
+ "status": "healthy",
11
+ "service": "checklist-agent"
12
+ }
app/routers/session.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, UploadFile, File, Form, HTTPException
2
+ from fastapi.responses import Response
3
+ from typing import Annotated, List
4
+ import uuid
5
+
6
+ from app.models.session import (
7
+ SessionStartResponse,
8
+ TranscribeResponse,
9
+ SubmitResponse,
10
+ SessionResultsResponse
11
+ )
12
+ from app.models.question import Question
13
+ from app.services.transcription import get_transcription_service
14
+ from app.agent.graph import checklist_agent
15
+ from app.agent.state import AgentState
16
+
17
+ router = APIRouter(prefix="/api/session", tags=["session"])
18
+
19
+ # In-memory хранилище сессий (для MVP)
20
+ sessions: dict[str, AgentState] = {}
21
+
22
+
23
+ @router.post("/start", response_model=SessionStartResponse)
24
+ async def start_session():
25
+ """Создает новую сессию и возвращает первые 3 вопроса"""
26
+ session_id = str(uuid.uuid4())[:8]
27
+
28
+ # Инициализируем состояние агента
29
+ initial_state: AgentState = {
30
+ "session_id": session_id,
31
+ "current_round": 0,
32
+ "max_rounds": 3,
33
+ "current_questions": [],
34
+ "all_answers": [],
35
+ "pending_transcripts": [],
36
+ "round_summaries": [],
37
+ "checklist_items": [],
38
+ "markdown_content": "",
39
+ "is_complete": False,
40
+ "waiting_for_answers": False
41
+ }
42
+
43
+ # Запускаем агент для генерации первых вопросов
44
+ result = checklist_agent.invoke(initial_state)
45
+
46
+ # Сохраняем состояние
47
+ sessions[session_id] = result
48
+
49
+ return SessionStartResponse(
50
+ session_id=session_id,
51
+ round=result["current_round"],
52
+ questions=result["current_questions"]
53
+ )
54
+
55
+
56
+ @router.post("/transcribe", response_model=TranscribeResponse)
57
+ async def transcribe_audio(
58
+ audio_file: Annotated[UploadFile, File(description="Audio file in webm format")]
59
+ ):
60
+ """Транскрибирует одно аудио и возвращает текст (для превью)"""
61
+ transcription_service = get_transcription_service()
62
+
63
+ audio_bytes = await audio_file.read()
64
+ transcript = await transcription_service.transcribe(audio_bytes)
65
+
66
+ return TranscribeResponse(transcript=transcript)
67
+
68
+
69
+ @router.post("/{session_id}/submit", response_model=SubmitResponse)
70
+ async def submit_answers(
71
+ session_id: str,
72
+ audio_files: Annotated[List[UploadFile], File(description="Audio files in webm format")],
73
+ question_ids: Annotated[str, Form(description="Comma-separated question IDs")]
74
+ ):
75
+ """Отправляет аудио-ответы и получает следующие вопросы или результат"""
76
+ if session_id not in sessions:
77
+ raise HTTPException(status_code=404, detail="Сессия не найдена")
78
+
79
+ state = sessions[session_id]
80
+ transcription_service = get_transcription_service()
81
+
82
+ # Транскрибируем все аудио
83
+ transcripts = []
84
+ for audio_file in audio_files:
85
+ audio_bytes = await audio_file.read()
86
+ transcript = await transcription_service.transcribe(audio_bytes)
87
+ transcripts.append(transcript)
88
+
89
+ # Обновляем состояние с транскриптами
90
+ state["pending_transcripts"] = transcripts
91
+ state["waiting_for_answers"] = False
92
+
93
+ # Запускаем обработку ответов
94
+ from app.agent.nodes import process_answers, analyze_round, generate_checklist, check_round_complete
95
+
96
+ # Обрабатываем ответы
97
+ updates = process_answers(state)
98
+ for key, value in updates.items():
99
+ state[key] = value
100
+
101
+ # Анализируем раунд
102
+ updates = analyze_round(state)
103
+ for key, value in updates.items():
104
+ state[key] = value
105
+
106
+ # Проверяем, нужно ли генерировать чеклист
107
+ current_round = state.get("current_round", 1)
108
+ if current_round > state.get("max_rounds", 3) or state.get("is_complete", False):
109
+ # Генерируем чеклист
110
+ updates = generate_checklist(state)
111
+ for key, value in updates.items():
112
+ state[key] = value
113
+
114
+ # Сохраняем обновленное состояние
115
+ sessions[session_id] = state
116
+
117
+ # Формируем ответ
118
+ if state.get("is_complete", False):
119
+ return SubmitResponse(
120
+ round=state["current_round"],
121
+ is_complete=True,
122
+ checklist_preview=state.get("markdown_content", ""),
123
+ round_summary=state["round_summaries"][-1] if state["round_summaries"] else None,
124
+ transcripts=transcripts
125
+ )
126
+ else:
127
+ return SubmitResponse(
128
+ round=state["current_round"],
129
+ is_complete=False,
130
+ questions=state.get("current_questions", []),
131
+ round_summary=state["round_summaries"][-1] if state["round_summaries"] else None,
132
+ transcripts=transcripts
133
+ )
134
+
135
+
136
+ @router.get("/{session_id}/results", response_model=SessionResultsResponse)
137
+ async def get_results(session_id: str):
138
+ """Получает финальный чеклист"""
139
+ if session_id not in sessions:
140
+ raise HTTPException(status_code=404, detail="Сессия не найдена")
141
+
142
+ state = sessions[session_id]
143
+
144
+ if not state.get("is_complete", False):
145
+ raise HTTPException(status_code=400, detail="Сессия еще не завершена")
146
+
147
+ return SessionResultsResponse(
148
+ session_id=session_id,
149
+ checklist=state.get("checklist_items", []),
150
+ markdown=state.get("markdown_content", "")
151
+ )
152
+
153
+
154
+ @router.get("/{session_id}/download")
155
+ async def download_checklist(session_id: str):
156
+ """Скачивает MD файл с чеклистом"""
157
+ if session_id not in sessions:
158
+ raise HTTPException(status_code=404, detail="Сессия не найдена")
159
+
160
+ state = sessions[session_id]
161
+
162
+ if not state.get("is_complete", False):
163
+ raise HTTPException(status_code=400, detail="Сессия еще не завершена")
164
+
165
+ markdown = state.get("markdown_content", "")
166
+
167
+ return Response(
168
+ content=markdown.encode("utf-8"),
169
+ media_type="text/markdown",
170
+ headers={
171
+ "Content-Disposition": f'attachment; filename="checklist-{session_id}.md"'
172
+ }
173
+ )
app/services/__init__.py ADDED
File without changes
app/services/file_generator.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+ from datetime import datetime
3
+ from app.models.checklist import ChecklistItem
4
+
5
+
6
+ class FileGenerator:
7
+ @staticmethod
8
+ def generate_markdown(
9
+ session_id: str,
10
+ checklist: List[ChecklistItem],
11
+ round_summaries: List[str]
12
+ ) -> str:
13
+ """Генерирует Markdown файл с результатами чеклиста"""
14
+
15
+ date = datetime.now().strftime("%Y-%m-%d %H:%M")
16
+
17
+ md_content = f"""# Чеклист созвона с клиентом
18
+
19
+ **Дата:** {date}
20
+ **Сессия:** {session_id}
21
+
22
+ ---
23
+
24
+ """
25
+ # Группируем по категориям
26
+ categories = {}
27
+ for item in checklist:
28
+ if item.category not in categories:
29
+ categories[item.category] = []
30
+ categories[item.category].append(item)
31
+
32
+ # Маппинг статусов на чекбоксы
33
+ status_map = {
34
+ "confirmed": "[x]",
35
+ "needs_clarification": "[ ] ⚠️",
36
+ "not_discussed": "[ ]"
37
+ }
38
+
39
+ for category, items in categories.items():
40
+ md_content += f"## {category}\n\n"
41
+ for item in items:
42
+ checkbox = status_map.get(item.status, "[ ]")
43
+ line = f"- {checkbox} {item.item}"
44
+ if item.notes:
45
+ line += f" *({item.notes})*"
46
+ md_content += line + "\n"
47
+ md_content += "\n"
48
+
49
+ # Добавляем саммари раундов
50
+ if round_summaries:
51
+ md_content += "---\n\n## Саммари интервью\n\n"
52
+ for i, summary in enumerate(round_summaries, 1):
53
+ md_content += f"**Раунд {i}:** {summary}\n\n"
54
+
55
+ md_content += """---
56
+
57
+ *Сгенерировано автоматически с помощью AI Checklist Agent*
58
+ """
59
+
60
+ return md_content
61
+
62
+
63
+ def get_file_generator() -> FileGenerator:
64
+ return FileGenerator()
app/services/llm.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from anthropic import Anthropic
2
+ from typing import List, Dict, Any
3
+ from app.config import get_settings
4
+ from app.models.question import Answer
5
+ from app.models.checklist import ChecklistItem
6
+ import json
7
+
8
+
9
+ class LLMService:
10
+ def __init__(self):
11
+ settings = get_settings()
12
+ self.client = Anthropic(api_key=settings.anthropic_api_key)
13
+ self.model = "claude-sonnet-4-20250514"
14
+
15
+ def generate_initial_questions(self) -> List[Dict[str, str]]:
16
+ """Генерирует первые 3 вопроса для начала интервью"""
17
+ response = self.client.messages.create(
18
+ model=self.model,
19
+ max_tokens=1024,
20
+ messages=[
21
+ {
22
+ "role": "user",
23
+ "content": """Ты - AI ассистент, который помогает заполнить чеклист созвона с клиентом.
24
+
25
+ Сгенерируй 3 начальных вопроса для клиента, чтобы понять суть его проекта.
26
+ Вопросы должны быть открытыми и направлены на выяснение:
27
+ 1. Общей информации о проекте
28
+ 2. Целей и задач
29
+ 3. Текущей ситуации
30
+
31
+ Ответ верни в формате JSON:
32
+ {
33
+ "questions": [
34
+ {"id": "q1", "text": "текст вопроса 1"},
35
+ {"id": "q2", "text": "текст вопроса 2"},
36
+ {"id": "q3", "text": "текст вопроса 3"}
37
+ ]
38
+ }
39
+
40
+ Только JSON, без дополнительного текста."""
41
+ }
42
+ ]
43
+ )
44
+
45
+ result = json.loads(response.content[0].text)
46
+ return result["questions"]
47
+
48
+ def analyze_round_and_generate_questions(
49
+ self,
50
+ round_number: int,
51
+ all_answers: List[Answer],
52
+ round_summaries: List[str]
53
+ ) -> Dict[str, Any]:
54
+ """Анализирует ответы раунда и генерирует следующие вопросы"""
55
+
56
+ answers_text = "\n".join([
57
+ f"Вопрос: {a.question_text}\nОтвет: {a.audio_transcript}"
58
+ for a in all_answers
59
+ ])
60
+
61
+ summaries_text = "\n".join([
62
+ f"Раунд {i+1}: {s}" for i, s in enumerate(round_summaries)
63
+ ]) if round_summaries else "Нет предыдущих саммари"
64
+
65
+ response = self.client.messages.create(
66
+ model=self.model,
67
+ max_tokens=2048,
68
+ messages=[
69
+ {
70
+ "role": "user",
71
+ "content": f"""Ты - AI ассистент для заполнения чеклиста созвона с клиентом.
72
+
73
+ Текущий раунд: {round_number}
74
+ Всего раундов: 3
75
+
76
+ Предыдущие саммари:
77
+ {summaries_text}
78
+
79
+ Все ответы клиента:
80
+ {answers_text}
81
+
82
+ Задача:
83
+ 1. Создай краткое саммари текущего раунда (2-3 предложения)
84
+ 2. Если это не последний раунд (раунд < 3), сгенерируй 3 уточняющих вопроса на основе полученных ответов
85
+
86
+ Ответ в формате JSON:
87
+ {{
88
+ "round_summary": "краткое саммари раунда",
89
+ "questions": [
90
+ {{"id": "q{round_number*3+1}", "text": "вопрос 1"}},
91
+ {{"id": "q{round_number*3+2}", "text": "вопрос 2"}},
92
+ {{"id": "q{round_number*3+3}", "text": "вопрос 3"}}
93
+ ]
94
+ }}
95
+
96
+ Если это раунд 3, поле "questions" может быть пустым массивом.
97
+ Только JSON, без дополнительного текста."""
98
+ }
99
+ ]
100
+ )
101
+
102
+ return json.loads(response.content[0].text)
103
+
104
+ def generate_checklist(
105
+ self,
106
+ all_answers: List[Answer],
107
+ round_summaries: List[str]
108
+ ) -> Dict[str, Any]:
109
+ """Генерирует финальный чеклист на основе всех ответов"""
110
+
111
+ answers_text = "\n".join([
112
+ f"Вопрос: {a.question_text}\nОтвет: {a.audio_transcript}"
113
+ for a in all_answers
114
+ ])
115
+
116
+ summaries_text = "\n".join([
117
+ f"Раунд {i+1}: {s}" for i, s in enumerate(round_summaries)
118
+ ])
119
+
120
+ response = self.client.messages.create(
121
+ model=self.model,
122
+ max_tokens=4096,
123
+ messages=[
124
+ {
125
+ "role": "user",
126
+ "content": f"""Ты - AI ассистент для заполнения чеклиста созвона с клиентом.
127
+
128
+ Саммари раундов:
129
+ {summaries_text}
130
+
131
+ Все ответы клиента:
132
+ {answers_text}
133
+
134
+ Создай структурированный чеклист созвона с клиентом.
135
+
136
+ Ответ в ��ормате JSON:
137
+ {{
138
+ "checklist": [
139
+ {{
140
+ "category": "Общая информация",
141
+ "item": "описание пункта",
142
+ "status": "confirmed",
143
+ "notes": "дополнительные заметки или null"
144
+ }}
145
+ ]
146
+ }}
147
+
148
+ Статусы:
149
+ - "confirmed" - информация получена и подтверждена
150
+ - "needs_clarification" - требует уточнения
151
+ - "not_discussed" - не обсуждалось
152
+
153
+ Категории могут быть:
154
+ - Общая информация
155
+ - Цели и задачи
156
+ - Сроки и бюджет
157
+ - Технические требования
158
+ - Дополнительные заметки
159
+
160
+ Только JSON, без дополнительного текста."""
161
+ }
162
+ ]
163
+ )
164
+
165
+ return json.loads(response.content[0].text)
166
+
167
+
168
+ def get_llm_service() -> LLMService:
169
+ return LLMService()
app/services/transcription.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import subprocess
2
+ import tempfile
3
+ import os
4
+ from functools import lru_cache
5
+ from transformers import pipeline
6
+ from app.config import get_settings
7
+
8
+
9
+ class TranscriptionService:
10
+ def __init__(self):
11
+ self._pipeline = None
12
+
13
+ def _get_pipeline(self):
14
+ if self._pipeline is None:
15
+ settings = get_settings()
16
+ self._pipeline = pipeline(
17
+ "automatic-speech-recognition",
18
+ model=settings.whisper_model,
19
+ device="cpu"
20
+ )
21
+ return self._pipeline
22
+
23
+ async def transcribe(self, audio_bytes: bytes) -> str:
24
+ """Транскрибирует аудио используя локальную модель Whisper с конвертацией через ffmpeg"""
25
+ tmp_webm = None
26
+ tmp_wav = None
27
+
28
+ try:
29
+ # Сохраняем webm во временный файл
30
+ with tempfile.NamedTemporaryFile(suffix=".webm", delete=False) as f:
31
+ f.write(audio_bytes)
32
+ tmp_webm = f.name
33
+
34
+ # Конвертируем webm в wav через ffmpeg
35
+ tmp_wav = tmp_webm.replace(".webm", ".wav")
36
+ process = subprocess.run(
37
+ [
38
+ "ffmpeg", "-i", tmp_webm,
39
+ "-ar", "16000", # 16kHz sample rate для Whisper
40
+ "-ac", "1", # моно
41
+ "-f", "wav",
42
+ "-y", # перезаписать
43
+ tmp_wav
44
+ ],
45
+ capture_output=True
46
+ )
47
+
48
+ if process.returncode != 0:
49
+ raise RuntimeError(f"FFmpeg failed: {process.stderr.decode()}")
50
+
51
+ # Передаем путь к файлу в pipeline
52
+ pipe = self._get_pipeline()
53
+ result = pipe(tmp_wav)
54
+ return result["text"].strip()
55
+
56
+ finally:
57
+ # Очищаем временные файлы
58
+ for path in [tmp_webm, tmp_wav]:
59
+ if path and os.path.exists(path):
60
+ try:
61
+ os.unlink(path)
62
+ except:
63
+ pass
64
+
65
+
66
+ @lru_cache()
67
+ def get_transcription_service() -> TranscriptionService:
68
+ return TranscriptionService()
app/utils/__init__.py ADDED
File without changes
requirements.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI & Server
2
+ fastapi==0.115.0
3
+ uvicorn[standard]==0.30.0
4
+ python-multipart==0.0.9
5
+
6
+ # LangGraph & LLM
7
+ langgraph==0.2.60
8
+ langchain-core==0.3.29
9
+ anthropic==0.40.0
10
+
11
+ # Whisper & Audio
12
+ transformers==4.44.0
13
+ torch==2.1.0
14
+ librosa==0.10.1
15
+ soundfile==0.12.1
16
+ accelerate==0.27.0
17
+
18
+ # CRITICAL: numpy<2 required for torch compatibility!
19
+ numpy<2
20
+
21
+ # Utilities
22
+ pydantic==2.9.0
23
+ pydantic-settings==2.5.0
24
+ python-dotenv==1.0.0
25
+ aiofiles==24.1.0