driopi commited on
Commit
b3eb783
·
verified ·
1 Parent(s): a7e7f41

Upload folder using huggingface_hub

Browse files
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_api_flow.py::test_full_9_question_flow_and_results",
3
+ "tests/test_api_flow.py::test_health",
4
+ "tests/test_api_flow.py::test_mock_mode_autogenerated_answers_flow",
5
+ "tests/test_api_flow.py::test_summary_audio_after_completion",
6
+ "tests/test_api_flow.py::test_transcribe_preview",
7
+ "tests/test_preflight_unittest.py::PreflightFlowTest::test_9_question_journey",
8
+ "tests/test_preflight_unittest.py::PreflightFlowTest::test_health"
9
+ ]
.pytest_cache/v/cache/stepwise ADDED
@@ -0,0 +1 @@
 
 
1
+ []
app/agent/state.py CHANGED
@@ -4,6 +4,7 @@ from app.models.checklist import ChecklistItem
4
  from app.models.portrait import PortraitCard
5
  from app.models.question import Question
6
  from app.models.session import Answer
 
7
 
8
 
9
  class AgentState(TypedDict):
@@ -18,6 +19,7 @@ class AgentState(TypedDict):
18
  round_summaries: List[str]
19
  round_summary: str
20
  checklist_items: List[ChecklistItem]
 
21
  portrait: Optional[PortraitCard]
22
  markdown_content: str
23
  is_complete: bool
 
4
  from app.models.portrait import PortraitCard
5
  from app.models.question import Question
6
  from app.models.session import Answer
7
+ from app.models.tooling import ToolInsight
8
 
9
 
10
  class AgentState(TypedDict):
 
19
  round_summaries: List[str]
20
  round_summary: str
21
  checklist_items: List[ChecklistItem]
22
+ tool_insights: List[ToolInsight]
23
  portrait: Optional[PortraitCard]
24
  markdown_content: str
25
  is_complete: bool
app/main.py CHANGED
@@ -12,6 +12,7 @@ from app.services.mcp import MCPToolProvider
12
  from app.services.portrait import PortraitService
13
  from app.services.transcription import TranscriptionService
14
  from app.services.tts import TTSService
 
15
  from app.storage.session_store import SessionStore
16
 
17
 
@@ -36,6 +37,7 @@ async def lifespan(app: FastAPI):
36
  app.state.mcp_provider = mcp_provider
37
  app.state.graph_service = ChecklistGraphService(llm_service, portrait_service=portrait_service)
38
  app.state.session_store = SessionStore()
 
39
 
40
  yield
41
 
 
12
  from app.services.portrait import PortraitService
13
  from app.services.transcription import TranscriptionService
14
  from app.services.tts import TTSService
15
+ from app.storage.job_store import JobStore
16
  from app.storage.session_store import SessionStore
17
 
18
 
 
37
  app.state.mcp_provider = mcp_provider
38
  app.state.graph_service = ChecklistGraphService(llm_service, portrait_service=portrait_service)
39
  app.state.session_store = SessionStore()
40
+ app.state.job_store = JobStore()
41
 
42
  yield
43
 
app/models/job.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Literal, Optional
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from app.models.question import Question
8
+
9
+ JobStatus = Literal["queued", "running", "completed", "failed"]
10
+ StepStatus = Literal["pending", "running", "completed", "failed"]
11
+
12
+
13
+ class JobStep(BaseModel):
14
+ key: str
15
+ label: str
16
+ status: StepStatus = "pending"
17
+ eta_seconds: int = 0
18
+
19
+
20
+ class JobResult(BaseModel):
21
+ round: int
22
+ questions: List[Question] = Field(default_factory=list)
23
+ round_summary: str
24
+ is_complete: bool
25
+ checklist_preview: Optional[str] = None
26
+
27
+
28
+ class SessionSubmitAcceptedResponse(BaseModel):
29
+ job_id: str
30
+ status: JobStatus
31
+ current_step: Optional[str] = None
32
+ eta_seconds_left: int
33
+ progress_pct: int
34
+
35
+
36
+ class JobStatusResponse(BaseModel):
37
+ job_id: str
38
+ session_id: str
39
+ status: JobStatus
40
+ current_step: Optional[str] = None
41
+ steps: List[JobStep] = Field(default_factory=list)
42
+ eta_seconds_left: int
43
+ progress_pct: int
44
+ error: Optional[str] = None
45
+ result: Optional[JobResult] = None
app/models/session.py CHANGED
@@ -5,11 +5,13 @@ from pydantic import BaseModel, Field
5
  from app.models.checklist import ChecklistItem
6
  from app.models.portrait import PortraitCard
7
  from app.models.question import Question
 
8
 
9
 
10
  class StartSessionRequest(BaseModel):
11
  goal: str = Field(default="Заполнить чеклист созвона с клиентом")
12
  topic: str = Field(default="Бриф по проекту")
 
13
 
14
 
15
  class Answer(BaseModel):
@@ -25,10 +27,12 @@ class SessionData(BaseModel):
25
  topic: str
26
  current_round: int = 1
27
  max_rounds: int = 3
 
28
  current_questions: List[Question] = Field(default_factory=list)
29
  all_answers: List[Answer] = Field(default_factory=list)
30
  round_summaries: List[str] = Field(default_factory=list)
31
  checklist_items: List[ChecklistItem] = Field(default_factory=list)
 
32
  portrait: Optional[PortraitCard] = None
33
  markdown_content: str = ""
34
  is_complete: bool = False
@@ -37,6 +41,7 @@ class SessionData(BaseModel):
37
  class SessionStartResponse(BaseModel):
38
  session_id: str
39
  round: int
 
40
  questions: List[Question]
41
 
42
 
@@ -48,10 +53,24 @@ class SessionSubmitResponse(BaseModel):
48
  checklist_preview: Optional[str] = None
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  class SessionResultsResponse(BaseModel):
52
  session_id: str
53
  is_complete: bool
54
  checklist: List[ChecklistItem]
 
55
  markdown: str
56
  round_summaries: List[str]
57
  portrait: Optional[PortraitCard] = None
 
5
  from app.models.checklist import ChecklistItem
6
  from app.models.portrait import PortraitCard
7
  from app.models.question import Question
8
+ from app.models.tooling import ToolInsight
9
 
10
 
11
  class StartSessionRequest(BaseModel):
12
  goal: str = Field(default="Заполнить чеклист созвона с клиентом")
13
  topic: str = Field(default="Бриф по проекту")
14
+ mock_mode: bool = Field(default=False)
15
 
16
 
17
  class Answer(BaseModel):
 
27
  topic: str
28
  current_round: int = 1
29
  max_rounds: int = 3
30
+ mock_mode: bool = False
31
  current_questions: List[Question] = Field(default_factory=list)
32
  all_answers: List[Answer] = Field(default_factory=list)
33
  round_summaries: List[str] = Field(default_factory=list)
34
  checklist_items: List[ChecklistItem] = Field(default_factory=list)
35
+ tool_insights: List[ToolInsight] = Field(default_factory=list)
36
  portrait: Optional[PortraitCard] = None
37
  markdown_content: str = ""
38
  is_complete: bool = False
 
41
  class SessionStartResponse(BaseModel):
42
  session_id: str
43
  round: int
44
+ mock_mode: bool = False
45
  questions: List[Question]
46
 
47
 
 
53
  checklist_preview: Optional[str] = None
54
 
55
 
56
+ class MockAnswerPreview(BaseModel):
57
+ question_id: str
58
+ question_text: str
59
+ transcript: str
60
+
61
+
62
+ class MockAnswersResponse(BaseModel):
63
+ session_id: str
64
+ round: int
65
+ answers: List[MockAnswerPreview]
66
+ logs: List[str] = Field(default_factory=list)
67
+
68
+
69
  class SessionResultsResponse(BaseModel):
70
  session_id: str
71
  is_complete: bool
72
  checklist: List[ChecklistItem]
73
+ tool_insights: List[ToolInsight]
74
  markdown: str
75
  round_summaries: List[str]
76
  portrait: Optional[PortraitCard] = None
app/models/tooling.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ToolInsight(BaseModel):
9
+ tool_name: str
10
+ title: str
11
+ summary: str
12
+ details: Dict[str, str] = Field(default_factory=dict)
app/routers/session.py CHANGED
@@ -8,14 +8,18 @@ from fastapi import APIRouter, HTTPException, Request
8
  from fastapi.responses import PlainTextResponse, Response
9
 
10
  from app.agent.state import AgentState
 
 
11
  from app.models.session import (
12
  Answer,
 
 
13
  SessionData,
14
  SessionResultsResponse,
15
  SessionStartResponse,
16
- SessionSubmitResponse,
17
  StartSessionRequest,
18
  )
 
19
 
20
  router = APIRouter(prefix="/api/session", tags=["session"])
21
 
@@ -27,6 +31,177 @@ def _decode_base64_audio(encoded: str) -> bytes:
27
  raise HTTPException(status_code=422, detail="Invalid audio_base64 payload") from exc
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  @router.post("/start", response_model=SessionStartResponse)
31
  async def start_session(payload: StartSessionRequest, request: Request):
32
  session_id = str(uuid4())
@@ -45,6 +220,7 @@ async def start_session(payload: StartSessionRequest, request: Request):
45
  "round_summaries": [],
46
  "round_summary": "",
47
  "checklist_items": [],
 
48
  "portrait": None,
49
  "markdown_content": "",
50
  "is_complete": False,
@@ -57,6 +233,7 @@ async def start_session(payload: StartSessionRequest, request: Request):
57
  topic=payload.topic,
58
  current_round=output["current_round"],
59
  max_rounds=3,
 
60
  current_questions=output["current_questions"],
61
  )
62
  session_store.create(session)
@@ -64,6 +241,7 @@ async def start_session(payload: StartSessionRequest, request: Request):
64
  return SessionStartResponse(
65
  session_id=session_id,
66
  round=session.current_round,
 
67
  questions=session.current_questions,
68
  )
69
 
@@ -77,10 +255,60 @@ async def get_session(session_id: str, request: Request):
77
  return SessionStartResponse(
78
  session_id=session.session_id,
79
  round=session.current_round,
 
80
  questions=session.current_questions,
81
  )
82
 
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  @router.post("/transcribe")
85
  async def transcribe_audio(request: Request):
86
  transcription_service = request.app.state.transcription_service
@@ -105,14 +333,13 @@ async def transcribe_audio(request: Request):
105
  return {"transcript": transcript}
106
 
107
 
108
- @router.post("/{session_id}/submit", response_model=SessionSubmitResponse)
109
  async def submit_answers(
110
  session_id: str,
111
  request: Request,
112
  ):
113
  store = request.app.state.session_store
114
- graph_service = request.app.state.graph_service
115
- transcription_service = request.app.state.transcription_service
116
 
117
  session = store.get(session_id)
118
  if not session:
@@ -121,6 +348,7 @@ async def submit_answers(
121
  raise HTTPException(status_code=400, detail="Session already completed")
122
 
123
  content_type = request.headers.get("content-type", "")
 
124
  if content_type.startswith("multipart/form-data"):
125
  form = await request.form()
126
  raw_question_ids = str(form.get("question_ids", ""))
@@ -133,74 +361,61 @@ async def submit_answers(
133
  payload = await request.json()
134
  raw_question_ids = str(payload.get("question_ids", ""))
135
  encoded_files = payload.get("audio_base64_files", [])
136
- files_payload = [(_decode_base64_audio(encoded), f"answer-{idx + 1}.webm") for idx, encoded in enumerate(encoded_files)]
 
 
 
 
 
 
 
137
 
138
  question_id_list = [item.strip() for item in raw_question_ids.split(",") if item.strip()]
139
- if len(files_payload) != 3 or len(question_id_list) != 3:
140
- raise HTTPException(status_code=422, detail="Expected 3 audio files and 3 question IDs")
141
-
142
- current_question_map = {q.id: q.text for q in session.current_questions}
143
-
144
- round_answers: list[Answer] = []
145
- for idx, (audio_bytes, filename) in enumerate(files_payload):
146
- transcript = await transcription_service.transcribe(audio_bytes, filename=filename)
147
- qid = question_id_list[idx]
148
- round_answers.append(
149
- Answer(
150
- question_id=qid,
151
- question_text=current_question_map.get(qid, f"Question {idx + 1}"),
152
- audio_transcript=transcript,
153
- round_number=session.current_round,
154
- )
155
- )
156
 
157
- all_answers = [*session.all_answers, *round_answers]
158
-
159
- state: AgentState = {
160
- "session_id": session.session_id,
161
- "goal": session.goal,
162
- "topic": session.topic,
163
- "current_round": session.current_round,
164
- "max_rounds": session.max_rounds,
165
- "current_questions": session.current_questions,
166
- "all_answers": all_answers,
167
- "latest_round_answers": round_answers,
168
- "round_summaries": session.round_summaries,
169
- "round_summary": "",
170
- "checklist_items": session.checklist_items,
171
- "portrait": session.portrait,
172
- "markdown_content": session.markdown_content,
173
- "is_complete": session.is_complete,
174
- }
175
 
176
- try:
177
- # Final round may include slower LLM/MCP calls; guard against infinite waits.
178
- output = await asyncio.wait_for(graph_service.advance(state), timeout=120.0)
179
- except asyncio.TimeoutError as exc:
180
- raise HTTPException(
181
- status_code=504,
182
- detail="Обработка раунда заняла слишком много времени. Повторите отправку.",
183
- ) from exc
184
-
185
- session.current_round = output["current_round"]
186
- session.current_questions = output.get("current_questions", [])
187
- session.all_answers = all_answers
188
- session.round_summaries = output.get("round_summaries", session.round_summaries)
189
- session.checklist_items = output.get("checklist_items", session.checklist_items)
190
- session.portrait = output.get("portrait", session.portrait)
191
- session.markdown_content = output.get("markdown_content", session.markdown_content)
192
- session.is_complete = output.get("is_complete", False)
193
- store.update(session)
194
-
195
- return SessionSubmitResponse(
196
- round=session.current_round,
197
- questions=session.current_questions,
198
- round_summary=output.get("round_summary", ""),
199
- is_complete=session.is_complete,
200
- checklist_preview=session.markdown_content if session.is_complete else None,
201
  )
202
 
203
 
 
 
 
 
 
 
 
 
 
204
  @router.get("/{session_id}/results", response_model=SessionResultsResponse)
205
  async def get_results(session_id: str, request: Request):
206
  store = request.app.state.session_store
@@ -212,6 +427,7 @@ async def get_results(session_id: str, request: Request):
212
  session_id=session.session_id,
213
  is_complete=session.is_complete,
214
  checklist=session.checklist_items,
 
215
  markdown=session.markdown_content,
216
  round_summaries=session.round_summaries,
217
  portrait=session.portrait,
 
8
  from fastapi.responses import PlainTextResponse, Response
9
 
10
  from app.agent.state import AgentState
11
+ from app.models.job import JobResult, JobStatusResponse, SessionSubmitAcceptedResponse
12
+ from app.models.question import Question
13
  from app.models.session import (
14
  Answer,
15
+ MockAnswerPreview,
16
+ MockAnswersResponse,
17
  SessionData,
18
  SessionResultsResponse,
19
  SessionStartResponse,
 
20
  StartSessionRequest,
21
  )
22
+ from app.services.file_generator import build_markdown
23
 
24
  router = APIRouter(prefix="/api/session", tags=["session"])
25
 
 
31
  raise HTTPException(status_code=422, detail="Invalid audio_base64 payload") from exc
32
 
33
 
34
+ def _job_steps_for_round(current_round: int, max_rounds: int) -> list[str]:
35
+ steps = ["transcribe_1", "transcribe_2", "transcribe_3", "analyze_round", "tool_planning", "tool_execution"]
36
+ if current_round < max_rounds:
37
+ steps.append("generate_next_questions")
38
+ else:
39
+ steps.append("finalize")
40
+ return steps
41
+
42
+
43
+ def _to_questions(texts: list[str]) -> list[Question]:
44
+ return [Question(id=str(uuid4()), text=text) for text in texts[:3]]
45
+
46
+
47
+ async def _process_submit_job(
48
+ *,
49
+ job_id: str,
50
+ session_id: str,
51
+ question_id_list: list[str],
52
+ files_payload: list[tuple[bytes, str]],
53
+ transcripts_payload: list[str] | None,
54
+ app,
55
+ ) -> None:
56
+ store = app.state.session_store
57
+ transcription_service = app.state.transcription_service
58
+ llm_service = app.state.llm_service
59
+ portrait_service = app.state.portrait_service
60
+ job_store = app.state.job_store
61
+
62
+ try:
63
+ job_store.mark_running(job_id)
64
+ session = store.get(session_id)
65
+ if not session:
66
+ raise RuntimeError("Session not found")
67
+ if session.is_complete:
68
+ raise RuntimeError("Session already completed")
69
+
70
+ current_question_map = {q.id: q.text for q in session.current_questions}
71
+ round_answers: list[Answer] = []
72
+
73
+ for idx, qid in enumerate(question_id_list):
74
+ step_key = f"transcribe_{idx + 1}"
75
+ job_store.mark_step_running(job_id, step_key)
76
+ if transcripts_payload is not None:
77
+ transcript = transcripts_payload[idx].strip()
78
+ else:
79
+ audio_bytes, filename = files_payload[idx]
80
+ transcript = await transcription_service.transcribe(audio_bytes, filename=filename)
81
+ job_store.mark_step_completed(job_id, step_key)
82
+ round_answers.append(
83
+ Answer(
84
+ question_id=qid,
85
+ question_text=current_question_map.get(qid, f"Question {idx + 1}"),
86
+ audio_transcript=transcript,
87
+ round_number=session.current_round,
88
+ )
89
+ )
90
+
91
+ all_answers = [*session.all_answers, *round_answers]
92
+
93
+ job_store.mark_step_running(job_id, "analyze_round")
94
+ summary_candidate = await llm_service.summarize_round(
95
+ round_number=session.current_round,
96
+ answers=round_answers,
97
+ )
98
+ round_summary = llm_service.ensure_distinct_round_summary(
99
+ round_number=session.current_round,
100
+ answers=round_answers,
101
+ previous_summaries=session.round_summaries,
102
+ candidate=summary_candidate,
103
+ )
104
+ round_summaries = [*session.round_summaries, round_summary]
105
+ job_store.mark_step_completed(job_id, "analyze_round")
106
+
107
+ target = "next_questions" if session.current_round < session.max_rounds else "final_checklist"
108
+
109
+ job_store.mark_step_running(job_id, "tool_planning")
110
+ planned_tools = llm_service.plan_tools_for_round(
111
+ round_number=session.current_round,
112
+ topic=session.topic,
113
+ all_answers=all_answers,
114
+ latest_round_answers=round_answers,
115
+ target=target,
116
+ )
117
+ job_store.mark_step_completed(job_id, "tool_planning")
118
+
119
+ job_store.mark_step_running(job_id, "tool_execution")
120
+ tool_insights = await llm_service.run_tools_for_round(
121
+ planned_tools=planned_tools,
122
+ topic=session.topic,
123
+ all_answers=all_answers,
124
+ )
125
+ tool_context = llm_service.render_tool_context(tool_insights)
126
+ job_store.mark_step_completed(job_id, "tool_execution")
127
+
128
+ if session.current_round < session.max_rounds:
129
+ job_store.mark_step_running(job_id, "generate_next_questions")
130
+ next_round = session.current_round + 1
131
+ next_questions_text = await llm_service.generate_next_questions(
132
+ goal=session.goal,
133
+ topic=session.topic,
134
+ all_answers=all_answers,
135
+ round_summaries=round_summaries,
136
+ next_round=next_round,
137
+ tool_context=tool_context,
138
+ )
139
+ next_questions = _to_questions(next_questions_text)
140
+ job_store.mark_step_completed(job_id, "generate_next_questions")
141
+
142
+ session.current_round = next_round
143
+ session.current_questions = next_questions
144
+ session.all_answers = all_answers
145
+ session.round_summaries = round_summaries
146
+ session.tool_insights = [*session.tool_insights, *tool_insights]
147
+ session.is_complete = False
148
+ store.update(session)
149
+
150
+ job_store.mark_completed(
151
+ job_id,
152
+ JobResult(
153
+ round=session.current_round,
154
+ questions=next_questions,
155
+ round_summary=round_summary,
156
+ is_complete=False,
157
+ checklist_preview=None,
158
+ ),
159
+ )
160
+ return
161
+
162
+ job_store.mark_step_running(job_id, "finalize")
163
+ checklist = await llm_service.build_final_checklist(
164
+ goal=session.goal,
165
+ topic=session.topic,
166
+ answers=all_answers,
167
+ round_summaries=round_summaries,
168
+ tool_context=tool_context,
169
+ )
170
+ portrait = portrait_service.analyze(all_answers)
171
+ all_tool_insights = [*session.tool_insights, *tool_insights]
172
+ markdown = build_markdown(
173
+ session_id=session.session_id,
174
+ topic=session.topic,
175
+ checklist=checklist,
176
+ answers=all_answers,
177
+ tool_insights=all_tool_insights,
178
+ )
179
+ job_store.mark_step_completed(job_id, "finalize")
180
+
181
+ session.current_questions = []
182
+ session.all_answers = all_answers
183
+ session.round_summaries = round_summaries
184
+ session.checklist_items = checklist
185
+ session.portrait = portrait
186
+ session.tool_insights = all_tool_insights
187
+ session.markdown_content = markdown
188
+ session.is_complete = True
189
+ store.update(session)
190
+
191
+ job_store.mark_completed(
192
+ job_id,
193
+ JobResult(
194
+ round=session.current_round,
195
+ questions=[],
196
+ round_summary=round_summary,
197
+ is_complete=True,
198
+ checklist_preview=markdown,
199
+ ),
200
+ )
201
+ except Exception as exc:
202
+ job_store.mark_failed(job_id, str(exc))
203
+
204
+
205
  @router.post("/start", response_model=SessionStartResponse)
206
  async def start_session(payload: StartSessionRequest, request: Request):
207
  session_id = str(uuid4())
 
220
  "round_summaries": [],
221
  "round_summary": "",
222
  "checklist_items": [],
223
+ "tool_insights": [],
224
  "portrait": None,
225
  "markdown_content": "",
226
  "is_complete": False,
 
233
  topic=payload.topic,
234
  current_round=output["current_round"],
235
  max_rounds=3,
236
+ mock_mode=payload.mock_mode,
237
  current_questions=output["current_questions"],
238
  )
239
  session_store.create(session)
 
241
  return SessionStartResponse(
242
  session_id=session_id,
243
  round=session.current_round,
244
+ mock_mode=session.mock_mode,
245
  questions=session.current_questions,
246
  )
247
 
 
255
  return SessionStartResponse(
256
  session_id=session.session_id,
257
  round=session.current_round,
258
+ mock_mode=session.mock_mode,
259
  questions=session.current_questions,
260
  )
261
 
262
 
263
+ @router.post("/{session_id}/mock-answers", response_model=MockAnswersResponse)
264
+ async def generate_mock_answers(session_id: str, request: Request):
265
+ store = request.app.state.session_store
266
+ llm_service = request.app.state.llm_service
267
+
268
+ session = store.get(session_id)
269
+ if not session:
270
+ raise HTTPException(status_code=404, detail="Session not found")
271
+ if session.is_complete:
272
+ raise HTTPException(status_code=400, detail="Session already completed")
273
+ if not session.mock_mode:
274
+ raise HTTPException(status_code=400, detail="Session is not in mock mode")
275
+
276
+ questions = session.current_questions
277
+ if len(questions) != 3:
278
+ raise HTTPException(status_code=400, detail="Expected exactly 3 active questions")
279
+
280
+ question_texts = [q.text for q in questions]
281
+ transcripts = await llm_service.generate_mock_answers(
282
+ goal=session.goal,
283
+ topic=session.topic,
284
+ round_number=session.current_round,
285
+ questions=question_texts,
286
+ )
287
+ if len(transcripts) < 3:
288
+ transcripts = [
289
+ *transcripts,
290
+ *["Нужны дополнительные вводные по этому пункту." for _ in range(3 - len(transcripts))],
291
+ ]
292
+
293
+ logs = [
294
+ "mock_mode=true: аудио не требуется, ответы сгенериро��аны автоматически",
295
+ f"Раунд {session.current_round}: создано {len(transcripts[:3])} транскриптов",
296
+ ]
297
+ return MockAnswersResponse(
298
+ session_id=session.session_id,
299
+ round=session.current_round,
300
+ answers=[
301
+ MockAnswerPreview(
302
+ question_id=q.id,
303
+ question_text=q.text,
304
+ transcript=transcripts[idx].strip(),
305
+ )
306
+ for idx, q in enumerate(questions[:3])
307
+ ],
308
+ logs=logs,
309
+ )
310
+
311
+
312
  @router.post("/transcribe")
313
  async def transcribe_audio(request: Request):
314
  transcription_service = request.app.state.transcription_service
 
333
  return {"transcript": transcript}
334
 
335
 
336
+ @router.post("/{session_id}/submit", response_model=SessionSubmitAcceptedResponse)
337
  async def submit_answers(
338
  session_id: str,
339
  request: Request,
340
  ):
341
  store = request.app.state.session_store
342
+ job_store = request.app.state.job_store
 
343
 
344
  session = store.get(session_id)
345
  if not session:
 
348
  raise HTTPException(status_code=400, detail="Session already completed")
349
 
350
  content_type = request.headers.get("content-type", "")
351
+ transcripts_payload: list[str] | None = None
352
  if content_type.startswith("multipart/form-data"):
353
  form = await request.form()
354
  raw_question_ids = str(form.get("question_ids", ""))
 
361
  payload = await request.json()
362
  raw_question_ids = str(payload.get("question_ids", ""))
363
  encoded_files = payload.get("audio_base64_files", [])
364
+ transcripts = payload.get("transcripts", [])
365
+ if transcripts:
366
+ if not session.mock_mode:
367
+ raise HTTPException(status_code=400, detail="transcripts mode is allowed only for mock_mode sessions")
368
+ transcripts_payload = [str(item).strip() for item in transcripts]
369
+ files_payload = []
370
+ else:
371
+ files_payload = [(_decode_base64_audio(encoded), f"answer-{idx + 1}.webm") for idx, encoded in enumerate(encoded_files)]
372
 
373
  question_id_list = [item.strip() for item in raw_question_ids.split(",") if item.strip()]
374
+ if len(question_id_list) != 3:
375
+ raise HTTPException(status_code=422, detail="Expected 3 question IDs")
376
+ if transcripts_payload is not None:
377
+ if len(transcripts_payload) != 3:
378
+ raise HTTPException(status_code=422, detail="Expected 3 transcripts in mock mode")
379
+ elif len(files_payload) != 3:
380
+ raise HTTPException(status_code=422, detail="Expected 3 audio files")
381
+
382
+ job_id = str(uuid4())
383
+ record = job_store.create(
384
+ job_id=job_id,
385
+ session_id=session_id,
386
+ step_keys=_job_steps_for_round(session.current_round, session.max_rounds),
387
+ )
 
 
 
388
 
389
+ asyncio.create_task(
390
+ _process_submit_job(
391
+ job_id=job_id,
392
+ session_id=session_id,
393
+ question_id_list=question_id_list,
394
+ files_payload=files_payload,
395
+ transcripts_payload=transcripts_payload,
396
+ app=request.app,
397
+ )
398
+ )
 
 
 
 
 
 
 
 
399
 
400
+ snapshot = record.as_response()
401
+ return SessionSubmitAcceptedResponse(
402
+ job_id=snapshot.job_id,
403
+ status=snapshot.status,
404
+ current_step=snapshot.current_step,
405
+ eta_seconds_left=snapshot.eta_seconds_left,
406
+ progress_pct=snapshot.progress_pct,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  )
408
 
409
 
410
+ @router.get("/jobs/{job_id}", response_model=JobStatusResponse)
411
+ async def get_submit_job(job_id: str, request: Request):
412
+ job_store = request.app.state.job_store
413
+ record = job_store.get(job_id)
414
+ if not record:
415
+ raise HTTPException(status_code=404, detail="Job not found")
416
+ return record.as_response()
417
+
418
+
419
  @router.get("/{session_id}/results", response_model=SessionResultsResponse)
420
  async def get_results(session_id: str, request: Request):
421
  store = request.app.state.session_store
 
427
  session_id=session.session_id,
428
  is_complete=session.is_complete,
429
  checklist=session.checklist_items,
430
+ tool_insights=session.tool_insights,
431
  markdown=session.markdown_content,
432
  round_summaries=session.round_summaries,
433
  portrait=session.portrait,
app/services/file_generator.py CHANGED
@@ -1,10 +1,19 @@
 
 
1
  from datetime import datetime
2
 
3
  from app.models.checklist import ChecklistItem
4
  from app.models.session import Answer
 
5
 
6
 
7
- def build_markdown(session_id: str, topic: str, checklist: list[ChecklistItem], answers: list[Answer]) -> str:
 
 
 
 
 
 
8
  lines: list[str] = []
9
  lines.append("# Чеклист созвона с клиентом")
10
  lines.append("")
@@ -30,6 +39,15 @@ def build_markdown(session_id: str, topic: str, checklist: list[ChecklistItem],
30
  lines.append(f"- Раунд {answer.round_number}: **{answer.question_text}**")
31
  lines.append(f" - {answer.audio_transcript}")
32
 
 
 
 
 
 
 
 
 
 
33
  lines.append("")
34
  lines.append("---")
35
  lines.append("*Сгенерировано автоматически AI Checklist Agent*")
 
1
+ from __future__ import annotations
2
+
3
  from datetime import datetime
4
 
5
  from app.models.checklist import ChecklistItem
6
  from app.models.session import Answer
7
+ from app.models.tooling import ToolInsight
8
 
9
 
10
+ def build_markdown(
11
+ session_id: str,
12
+ topic: str,
13
+ checklist: list[ChecklistItem],
14
+ answers: list[Answer],
15
+ tool_insights: list[ToolInsight] | None = None,
16
+ ) -> str:
17
  lines: list[str] = []
18
  lines.append("# Чеклист созвона с клиентом")
19
  lines.append("")
 
39
  lines.append(f"- Раунд {answer.round_number}: **{answer.question_text}**")
40
  lines.append(f" - {answer.audio_transcript}")
41
 
42
+ if tool_insights:
43
+ lines.append("")
44
+ lines.append("## Инструменты агента")
45
+ for insight in tool_insights:
46
+ lines.append(f"- **{insight.title}**: {insight.summary}")
47
+ if insight.details:
48
+ details = "; ".join(f"{k}: {v}" for k, v in insight.details.items())
49
+ lines.append(f" - {details}")
50
+
51
  lines.append("")
52
  lines.append("---")
53
  lines.append("*Сгенерировано автоматически AI Checklist Agent*")
app/services/insight_tools.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import re
5
+ import sqlite3
6
+ from collections import Counter
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from app.models.session import Answer
10
+ from app.models.tooling import ToolInsight
11
+ from app.services.mcp import MCPToolProvider
12
+
13
+ _RU_STOPWORDS = {
14
+ "и",
15
+ "в",
16
+ "во",
17
+ "на",
18
+ "по",
19
+ "с",
20
+ "со",
21
+ "к",
22
+ "у",
23
+ "для",
24
+ "из",
25
+ "а",
26
+ "но",
27
+ "что",
28
+ "как",
29
+ "это",
30
+ "мы",
31
+ "вы",
32
+ "они",
33
+ "он",
34
+ "она",
35
+ "не",
36
+ "да",
37
+ "или",
38
+ "ли",
39
+ "бы",
40
+ "быть",
41
+ "есть",
42
+ "будет",
43
+ "уже",
44
+ "еще",
45
+ "очень",
46
+ "тема",
47
+ "проект",
48
+ }
49
+
50
+ _UNCERTAINTY_MARKERS = (
51
+ "не знаю",
52
+ "наверно",
53
+ "наверное",
54
+ "возможно",
55
+ "может быть",
56
+ "пока не",
57
+ "сложно сказать",
58
+ "уточнить",
59
+ "не уверен",
60
+ )
61
+
62
+ _CALCULATOR_HINTS = (
63
+ "бюджет",
64
+ "срок",
65
+ "дней",
66
+ "недель",
67
+ "месяц",
68
+ "процент",
69
+ "%",
70
+ "стоимость",
71
+ "цена",
72
+ "доход",
73
+ "расход",
74
+ )
75
+
76
+
77
+ class InsightToolsService:
78
+ def __init__(self, mcp_provider: Optional[MCPToolProvider] = None) -> None:
79
+ self._mcp_provider = mcp_provider
80
+
81
+ def plan_tools(
82
+ self,
83
+ *,
84
+ round_number: int,
85
+ topic: str,
86
+ all_answers: List[Answer],
87
+ latest_round_answers: List[Answer],
88
+ target: str,
89
+ ) -> List[str]:
90
+ planned = ["session_db"]
91
+
92
+ transcript_pool = " ".join(a.audio_transcript.lower() for a in latest_round_answers or all_answers)
93
+ has_digits = bool(re.search(r"\d", transcript_pool))
94
+ has_calc_hints = any(hint in transcript_pool for hint in _CALCULATOR_HINTS)
95
+
96
+ if target == "next_questions" or round_number <= 2:
97
+ planned.append("research")
98
+
99
+ if has_digits or has_calc_hints:
100
+ planned.append("calculator")
101
+
102
+ # Keep tool set stable for final round even when numbers are absent.
103
+ if target == "final_checklist" and "calculator" not in planned:
104
+ planned.append("calculator")
105
+
106
+ # Preserve order, remove accidental duplicates.
107
+ ordered_unique: list[str] = []
108
+ for item in planned:
109
+ if item not in ordered_unique:
110
+ ordered_unique.append(item)
111
+ return ordered_unique
112
+
113
+ async def run_tools(
114
+ self,
115
+ *,
116
+ planned_tools: List[str],
117
+ topic: str,
118
+ all_answers: List[Answer],
119
+ ) -> List[ToolInsight]:
120
+ out: list[ToolInsight] = []
121
+ for tool_name in planned_tools:
122
+ if tool_name == "session_db":
123
+ out.append(self._session_db_tool(all_answers))
124
+ elif tool_name == "calculator":
125
+ out.append(self._calculator_tool(all_answers))
126
+ elif tool_name == "research":
127
+ out.append(await self._research_tool(topic))
128
+ return out
129
+
130
+ @staticmethod
131
+ def render_context(insights: List[ToolInsight]) -> str:
132
+ if not insights:
133
+ return ""
134
+ lines = ["Инструментальные наблюдения:"]
135
+ for idx, insight in enumerate(insights, start=1):
136
+ details = "; ".join(f"{k}: {v}" for k, v in insight.details.items() if str(v).strip())
137
+ if details:
138
+ lines.append(f"{idx}. {insight.title}: {insight.summary} ({details})")
139
+ else:
140
+ lines.append(f"{idx}. {insight.title}: {insight.summary}")
141
+ return "\n".join(lines)
142
+
143
+ def _session_db_tool(self, answers: List[Answer]) -> ToolInsight:
144
+ conn = sqlite3.connect(":memory:")
145
+ try:
146
+ conn.execute(
147
+ "CREATE TABLE answers (round_number INTEGER, question_text TEXT, transcript TEXT)"
148
+ )
149
+ conn.executemany(
150
+ "INSERT INTO answers(round_number, question_text, transcript) VALUES (?, ?, ?)",
151
+ [(a.round_number, a.question_text, a.audio_transcript) for a in answers],
152
+ )
153
+ row = conn.execute(
154
+ "SELECT COUNT(*), AVG(LENGTH(transcript)), COUNT(DISTINCT round_number) FROM answers"
155
+ ).fetchone()
156
+ total_answers = int(row[0] or 0)
157
+ avg_len = int(round(float(row[1] or 0.0)))
158
+ rounds_covered = int(row[2] or 0)
159
+
160
+ joined = " ".join(a.audio_transcript.lower() for a in answers)
161
+ tokens = re.findall(r"[a-zA-Zа-яА-ЯёЁ0-9]{3,}", joined)
162
+ words = [w for w in tokens if w not in _RU_STOPWORDS and not w.isdigit()]
163
+ top_words = [word for word, _count in Counter(words).most_common(5)]
164
+ uncertainty_hits = sum(1 for marker in _UNCERTAINTY_MARKERS if marker in joined)
165
+
166
+ summary = (
167
+ f"В базе {total_answers} ответов по {rounds_covered} раундам; "
168
+ f"средняя длина ответа {avg_len} символов."
169
+ )
170
+ details = {
171
+ "топ-темы": ", ".join(top_words) if top_words else "нет выраженных тем",
172
+ "маркеры_неопределенности": str(uncertainty_hits),
173
+ }
174
+ return ToolInsight(
175
+ tool_name="session_db",
176
+ title="Session DB Lens",
177
+ summary=summary,
178
+ details=details,
179
+ )
180
+ finally:
181
+ conn.close()
182
+
183
+ def _calculator_tool(self, answers: List[Answer]) -> ToolInsight:
184
+ text = " ".join(a.audio_transcript for a in answers)
185
+ raw_numbers = re.findall(r"\d+(?:[.,]\d+)?", text)
186
+ values = [float(item.replace(",", ".")) for item in raw_numbers]
187
+ percent_mentions = len(re.findall(r"\d+(?:[.,]\d+)?\s*%", text))
188
+
189
+ if not values:
190
+ return ToolInsight(
191
+ tool_name="calculator",
192
+ title="Numeric Estimator",
193
+ summary="Числовые ориентиры не обнаружены; стоит запросить KPI, бюджет и сроки в цифрах.",
194
+ details={"чисел": "0", "проценты": str(percent_mentions)},
195
+ )
196
+
197
+ avg_value = sum(values) / len(values)
198
+ summary = (
199
+ f"Найдены числовые ориентиры: {len(values)} значений, "
200
+ f"диапазон {min(values):.0f}-{max(values):.0f}, среднее {avg_value:.1f}."
201
+ )
202
+ return ToolInsight(
203
+ tool_name="calculator",
204
+ title="Numeric Estimator",
205
+ summary=summary,
206
+ details={
207
+ "чисел": str(len(values)),
208
+ "минимум": f"{min(values):.0f}",
209
+ "максимум": f"{max(values):.0f}",
210
+ "проценты": str(percent_mentions),
211
+ },
212
+ )
213
+
214
+ async def _research_tool(self, topic: str) -> ToolInsight:
215
+ fallback = self._fallback_research(topic)
216
+ if self._mcp_provider is None:
217
+ return fallback
218
+
219
+ try:
220
+ tools = await asyncio.wait_for(self._mcp_provider.get_tools(), timeout=8.0)
221
+ except Exception:
222
+ return fallback
223
+
224
+ if not tools:
225
+ return fallback
226
+
227
+ for tool in tools[:2]:
228
+ try:
229
+ result = await asyncio.wait_for(tool.ainvoke({"query": topic}), timeout=7.0)
230
+ except Exception:
231
+ try:
232
+ result = await asyncio.wait_for(tool.ainvoke(topic), timeout=7.0)
233
+ except Exception:
234
+ continue
235
+ text = re.sub(r"\s+", " ", str(result)).strip()
236
+ if not text:
237
+ continue
238
+ snippet = text[:260]
239
+ return ToolInsight(
240
+ tool_name="research",
241
+ title="Research Probe",
242
+ summary=f"MCP-результат по теме '{topic}': {snippet}",
243
+ details={"источник": "mcp", "длина": str(len(text))},
244
+ )
245
+
246
+ return fallback
247
+
248
+ @staticmethod
249
+ def _fallback_research(topic: str) -> ToolInsight:
250
+ normalized = topic.lower()
251
+ if "теннис" in normalized:
252
+ summary = (
253
+ "Для турниров критичны логистика кортов, сетка матчей, судейство, "
254
+ "питание и сценарий непогоды."
255
+ )
256
+ notes = "расписание, регламент, риски переноса"
257
+ else:
258
+ summary = (
259
+ "Для discovery-интервью обычно важны KPI, владелец процесса, "
260
+ "ограничения бюджета/сроков и критерии успеха пилота."
261
+ )
262
+ notes = "kpi, роли, дедлайны, критерии stop/go"
263
+
264
+ return ToolInsight(
265
+ tool_name="research",
266
+ title="Research Probe",
267
+ summary=summary,
268
+ details={"источник": "fallback", "ключевые_узлы": notes},
269
+ )
app/services/llm.py CHANGED
@@ -1,6 +1,5 @@
1
  from __future__ import annotations
2
 
3
- import asyncio
4
  import json
5
  import logging
6
  import re
@@ -11,6 +10,8 @@ import httpx
11
  from app.config import Settings
12
  from app.models.checklist import ChecklistItem
13
  from app.models.session import Answer
 
 
14
  from app.services.mcp import MCPToolProvider
15
 
16
  logger = logging.getLogger(__name__)
@@ -20,6 +21,7 @@ class LLMService:
20
  def __init__(self, settings: Settings, mcp_provider: Optional[MCPToolProvider] = None) -> None:
21
  self.settings = settings
22
  self._mcp_provider = mcp_provider
 
23
  self._provider = settings.llm_provider.lower().strip()
24
  self._model = None
25
 
@@ -68,29 +70,39 @@ class LLMService:
68
 
69
  return None
70
 
71
- async def _research_context(self, topic: str) -> str:
72
- if self._mcp_provider is None:
73
- return ""
74
-
75
- try:
76
- tools = await asyncio.wait_for(self._mcp_provider.get_tools(), timeout=10.0)
77
- except Exception:
78
- return ""
79
- if not tools:
80
- return ""
 
 
 
 
 
 
81
 
82
- snippets: list[str] = []
83
- for tool in tools[:2]:
84
- try:
85
- result = await asyncio.wait_for(tool.ainvoke({"query": topic}), timeout=6.0)
86
- except Exception:
87
- try:
88
- result = await asyncio.wait_for(tool.ainvoke(topic), timeout=6.0)
89
- except Exception:
90
- continue
91
- snippets.append(str(result)[:500])
 
 
92
 
93
- return "\n".join(snippets)
 
 
94
 
95
  async def generate_initial_questions(self, goal: str, topic: str) -> list[str]:
96
  prompt = (
@@ -115,13 +127,13 @@ class LLMService:
115
  all_answers: List[Answer],
116
  round_summaries: List[str],
117
  next_round: int,
 
118
  ) -> list[str]:
119
  previous_questions = [a.question_text for a in all_answers]
120
  answer_dump = "\n".join(
121
  [f"- {a.question_text}: {a.audio_transcript}" for a in all_answers]
122
  )
123
  summary_dump = "\n".join(round_summaries)
124
- research = await self._research_context(topic)
125
  prompt = (
126
  "На основе ответов и summary создай ровно 3 уточняющих вопроса. "
127
  "Новые вопросы не должны дублировать старые. "
@@ -131,7 +143,7 @@ class LLMService:
131
  f"Раунд: {next_round}\n"
132
  f"Summary: {summary_dump}\n"
133
  f"Ответы: {answer_dump}\n"
134
- f"Внешний контекст (MCP Tavily/HF): {research}\n"
135
  )
136
  response_text = await self._invoke_text(prompt)
137
  if response_text:
@@ -159,6 +171,38 @@ class LLMService:
159
 
160
  return response_text
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  def ensure_distinct_round_summary(
163
  self,
164
  round_number: int,
@@ -182,10 +226,10 @@ class LLMService:
182
  topic: str,
183
  answers: List[Answer],
184
  round_summaries: List[str],
 
185
  ) -> list[ChecklistItem]:
186
  answers_dump = "\n".join([f"- {a.question_text}: {a.audio_transcript}" for a in answers])
187
  summary_dump = "\n".join(round_summaries)
188
- research = await self._research_context(topic)
189
 
190
  prompt = (
191
  "Построй итоговый checklist в JSON. Формат: "
@@ -194,7 +238,7 @@ class LLMService:
194
  f"Цель: {goal}\nТема: {topic}\n"
195
  f"Summary: {summary_dump}\n"
196
  f"Ответы:\n{answers_dump}\n"
197
- f"Внешний контекст (MCP Tavily/HF): {research}\n"
198
  "Верни только JSON-массив."
199
  )
200
 
 
1
  from __future__ import annotations
2
 
 
3
  import json
4
  import logging
5
  import re
 
10
  from app.config import Settings
11
  from app.models.checklist import ChecklistItem
12
  from app.models.session import Answer
13
+ from app.models.tooling import ToolInsight
14
+ from app.services.insight_tools import InsightToolsService
15
  from app.services.mcp import MCPToolProvider
16
 
17
  logger = logging.getLogger(__name__)
 
21
  def __init__(self, settings: Settings, mcp_provider: Optional[MCPToolProvider] = None) -> None:
22
  self.settings = settings
23
  self._mcp_provider = mcp_provider
24
+ self._insight_tools = InsightToolsService(mcp_provider=mcp_provider)
25
  self._provider = settings.llm_provider.lower().strip()
26
  self._model = None
27
 
 
70
 
71
  return None
72
 
73
+ def plan_tools_for_round(
74
+ self,
75
+ *,
76
+ round_number: int,
77
+ topic: str,
78
+ all_answers: List[Answer],
79
+ latest_round_answers: List[Answer],
80
+ target: str,
81
+ ) -> List[str]:
82
+ return self._insight_tools.plan_tools(
83
+ round_number=round_number,
84
+ topic=topic,
85
+ all_answers=all_answers,
86
+ latest_round_answers=latest_round_answers,
87
+ target=target,
88
+ )
89
 
90
+ async def run_tools_for_round(
91
+ self,
92
+ *,
93
+ planned_tools: List[str],
94
+ topic: str,
95
+ all_answers: List[Answer],
96
+ ) -> List[ToolInsight]:
97
+ return await self._insight_tools.run_tools(
98
+ planned_tools=planned_tools,
99
+ topic=topic,
100
+ all_answers=all_answers,
101
+ )
102
 
103
+ @staticmethod
104
+ def render_tool_context(insights: List[ToolInsight]) -> str:
105
+ return InsightToolsService.render_context(insights)
106
 
107
  async def generate_initial_questions(self, goal: str, topic: str) -> list[str]:
108
  prompt = (
 
127
  all_answers: List[Answer],
128
  round_summaries: List[str],
129
  next_round: int,
130
+ tool_context: str = "",
131
  ) -> list[str]:
132
  previous_questions = [a.question_text for a in all_answers]
133
  answer_dump = "\n".join(
134
  [f"- {a.question_text}: {a.audio_transcript}" for a in all_answers]
135
  )
136
  summary_dump = "\n".join(round_summaries)
 
137
  prompt = (
138
  "На основе ответов и summary создай ровно 3 уточняющих вопроса. "
139
  "Новые вопросы не должны дублировать старые. "
 
143
  f"Раунд: {next_round}\n"
144
  f"Summary: {summary_dump}\n"
145
  f"Ответы: {answer_dump}\n"
146
+ f"{tool_context}\n"
147
  )
148
  response_text = await self._invoke_text(prompt)
149
  if response_text:
 
171
 
172
  return response_text
173
 
174
+ async def generate_mock_answers(
175
+ self,
176
+ *,
177
+ goal: str,
178
+ topic: str,
179
+ round_number: int,
180
+ questions: List[str],
181
+ ) -> list[str]:
182
+ question_dump = "\n".join([f"{idx + 1}. {q}" for idx, q in enumerate(questions)])
183
+ prompt = (
184
+ "Ты играешь роль респондента интервью. "
185
+ "Сгенерируй реалистичные короткие ответы на каждый вопрос (1-3 предложения). "
186
+ "Верни строго JSON-массив строк той же длины, что и список вопросов, без комментариев.\n"
187
+ f"Цель интервью: {goal}\n"
188
+ f"Тема: {topic}\n"
189
+ f"Раунд: {round_number}\n"
190
+ f"Вопросы:\n{question_dump}\n"
191
+ )
192
+ response_text = await self._invoke_text(prompt)
193
+ if response_text:
194
+ parsed = self._parse_questions(response_text)
195
+ if len(parsed) >= len(questions):
196
+ return parsed[: len(questions)]
197
+
198
+ fallback = []
199
+ for idx, question in enumerate(questions, start=1):
200
+ fallback.append(
201
+ f"По вопросу {idx}: для темы '{topic}' приоритетом считаем измеримый результат и реалистичный план выполнения. "
202
+ f"Уточним детали после пилота. ({self._shorten(question, limit=80)})"
203
+ )
204
+ return fallback[: len(questions)]
205
+
206
  def ensure_distinct_round_summary(
207
  self,
208
  round_number: int,
 
226
  topic: str,
227
  answers: List[Answer],
228
  round_summaries: List[str],
229
+ tool_context: str = "",
230
  ) -> list[ChecklistItem]:
231
  answers_dump = "\n".join([f"- {a.question_text}: {a.audio_transcript}" for a in answers])
232
  summary_dump = "\n".join(round_summaries)
 
233
 
234
  prompt = (
235
  "Построй итоговый checklist в JSON. Формат: "
 
238
  f"Цель: {goal}\nТема: {topic}\n"
239
  f"Summary: {summary_dump}\n"
240
  f"Ответы:\n{answers_dump}\n"
241
+ f"{tool_context}\n"
242
  "Верни только JSON-массив."
243
  )
244
 
app/storage/job_store.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Dict, Optional
5
+
6
+ from app.models.job import JobResult, JobStatus, JobStatusResponse, JobStep
7
+
8
+
9
+ DEFAULT_STEP_ETAS: dict[str, int] = {
10
+ "transcribe_1": 6,
11
+ "transcribe_2": 6,
12
+ "transcribe_3": 6,
13
+ "analyze_round": 8,
14
+ "tool_planning": 3,
15
+ "tool_execution": 5,
16
+ "generate_next_questions": 6,
17
+ "finalize": 10,
18
+ }
19
+
20
+ STEP_LABELS: dict[str, str] = {
21
+ "transcribe_1": "Транскрибация ответа 1/3",
22
+ "transcribe_2": "Транскрибация ответа 2/3",
23
+ "transcribe_3": "Транскрибация ответа 3/3",
24
+ "analyze_round": "Анализ ответов раунда",
25
+ "tool_planning": "Планирование вызова инструментов",
26
+ "tool_execution": "Выполнение инструментов",
27
+ "generate_next_questions": "Генерация следующих вопросов",
28
+ "finalize": "Генерация финального резюме и чеклиста",
29
+ }
30
+
31
+
32
+ class JobRecord:
33
+ def __init__(self, job_id: str, session_id: str, steps: list[JobStep]) -> None:
34
+ self.job_id = job_id
35
+ self.session_id = session_id
36
+ self.status: JobStatus = "queued"
37
+ self.current_step: Optional[str] = None
38
+ self.steps = steps
39
+ self.error: Optional[str] = None
40
+ self.result: Optional[JobResult] = None
41
+ self._started_at = time.monotonic()
42
+ self._step_started_at: Optional[float] = None
43
+
44
+ def _eta_left(self) -> int:
45
+ remaining = 0.0
46
+ for step in self.steps:
47
+ if step.status == "completed":
48
+ continue
49
+ if step.status == "running" and self._step_started_at is not None:
50
+ elapsed = max(0.0, time.monotonic() - self._step_started_at)
51
+ remaining += max(0.0, step.eta_seconds - elapsed)
52
+ else:
53
+ remaining += step.eta_seconds
54
+ return int(round(remaining))
55
+
56
+ def _progress_pct(self) -> int:
57
+ if not self.steps:
58
+ return 0
59
+ done = sum(1 for step in self.steps if step.status == "completed")
60
+ if self.status == "completed":
61
+ return 100
62
+ return int((done / len(self.steps)) * 100)
63
+
64
+ def as_response(self) -> JobStatusResponse:
65
+ return JobStatusResponse(
66
+ job_id=self.job_id,
67
+ session_id=self.session_id,
68
+ status=self.status,
69
+ current_step=self.current_step,
70
+ steps=self.steps,
71
+ eta_seconds_left=self._eta_left(),
72
+ progress_pct=self._progress_pct(),
73
+ error=self.error,
74
+ result=self.result,
75
+ )
76
+
77
+
78
+ class JobStore:
79
+ def __init__(self) -> None:
80
+ self._jobs: Dict[str, JobRecord] = {}
81
+ self._step_etas = dict(DEFAULT_STEP_ETAS)
82
+
83
+ def _step_eta(self, key: str) -> int:
84
+ return int(self._step_etas.get(key, 5))
85
+
86
+ def create(self, job_id: str, session_id: str, step_keys: list[str]) -> JobRecord:
87
+ steps = [
88
+ JobStep(
89
+ key=step_key,
90
+ label=STEP_LABELS.get(step_key, step_key),
91
+ eta_seconds=self._step_eta(step_key),
92
+ )
93
+ for step_key in step_keys
94
+ ]
95
+ record = JobRecord(job_id=job_id, session_id=session_id, steps=steps)
96
+ self._jobs[job_id] = record
97
+ return record
98
+
99
+ def get(self, job_id: str) -> Optional[JobRecord]:
100
+ return self._jobs.get(job_id)
101
+
102
+ def mark_running(self, job_id: str) -> None:
103
+ record = self._jobs[job_id]
104
+ record.status = "running"
105
+
106
+ def mark_step_running(self, job_id: str, step_key: str) -> None:
107
+ record = self._jobs[job_id]
108
+ record.current_step = step_key
109
+ record._step_started_at = time.monotonic()
110
+ for step in record.steps:
111
+ if step.key == step_key:
112
+ step.status = "running"
113
+ break
114
+
115
+ def mark_step_completed(self, job_id: str, step_key: str) -> None:
116
+ record = self._jobs[job_id]
117
+ duration = 0.0
118
+ if record._step_started_at is not None:
119
+ duration = max(0.0, time.monotonic() - record._step_started_at)
120
+ for step in record.steps:
121
+ if step.key == step_key:
122
+ step.status = "completed"
123
+ if duration > 0:
124
+ prev = float(self._step_etas.get(step.key, step.eta_seconds))
125
+ self._step_etas[step.key] = max(1, int(round(prev * 0.75 + duration * 0.25)))
126
+ break
127
+ record._step_started_at = None
128
+
129
+ def mark_failed(self, job_id: str, error: str) -> None:
130
+ record = self._jobs[job_id]
131
+ record.status = "failed"
132
+ record.error = error
133
+ if record.current_step:
134
+ for step in record.steps:
135
+ if step.key == record.current_step and step.status == "running":
136
+ step.status = "failed"
137
+ break
138
+
139
+ def mark_completed(self, job_id: str, result: JobResult) -> None:
140
+ record = self._jobs[job_id]
141
+ record.status = "completed"
142
+ record.result = result
143
+ record.current_step = None
144
+ for step in record.steps:
145
+ if step.status == "running":
146
+ step.status = "completed"
tests/__pycache__/test_preflight_unittest.cpython-314.pyc CHANGED
Binary files a/tests/__pycache__/test_preflight_unittest.cpython-314.pyc and b/tests/__pycache__/test_preflight_unittest.cpython-314.pyc differ
 
tests/test_api_flow.py CHANGED
@@ -1,10 +1,22 @@
1
  import base64
 
2
 
3
 
4
  def _fake_webm_b64() -> str:
5
  return base64.b64encode(b"RIFF....FAKEAUDIO").decode("utf-8")
6
 
7
 
 
 
 
 
 
 
 
 
 
 
 
8
  def _complete_session(client):
9
  start = client.post(
10
  "/api/session/start",
@@ -27,7 +39,13 @@ def _complete_session(client):
27
  }
28
  submit = client.post(f"/api/session/{session_id}/submit", json=payload)
29
  assert submit.status_code == 200
30
- payload = submit.json()
 
 
 
 
 
 
31
  assert payload["round_summary"]
32
 
33
  if expected_round < 3:
@@ -45,6 +63,46 @@ def _complete_session(client):
45
  return session_id
46
 
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  def test_health(client):
49
  res = client.get("/health")
50
  assert res.status_code == 200
@@ -61,6 +119,7 @@ def test_full_9_question_flow_and_results(client):
61
  results_payload = results.json()
62
  assert results_payload["is_complete"] is True
63
  assert len(results_payload["checklist"]) >= 1
 
64
  assert "Чеклист созвона" in results_payload["markdown"]
65
  assert results_payload["portrait"] is not None
66
  assert 1 <= results_payload["portrait"]["emotional_stability"] <= 10
@@ -89,3 +148,12 @@ def test_transcribe_preview(client):
89
  )
90
  assert res.status_code == 200
91
  assert "mock transcript" in res.json()["transcript"]
 
 
 
 
 
 
 
 
 
 
1
  import base64
2
+ import time
3
 
4
 
5
  def _fake_webm_b64() -> str:
6
  return base64.b64encode(b"RIFF....FAKEAUDIO").decode("utf-8")
7
 
8
 
9
+ def _wait_job_completed(client, job_id: str, max_attempts: int = 300):
10
+ for _ in range(max_attempts):
11
+ status = client.get(f"/api/session/jobs/{job_id}")
12
+ assert status.status_code == 200
13
+ payload = status.json()
14
+ if payload["status"] in {"completed", "failed"}:
15
+ return payload
16
+ time.sleep(0.01)
17
+ raise AssertionError("submit job did not finish in time")
18
+
19
+
20
  def _complete_session(client):
21
  start = client.post(
22
  "/api/session/start",
 
39
  }
40
  submit = client.post(f"/api/session/{session_id}/submit", json=payload)
41
  assert submit.status_code == 200
42
+ accepted = submit.json()
43
+ assert accepted["job_id"]
44
+
45
+ completed = _wait_job_completed(client, accepted["job_id"])
46
+ assert completed["status"] == "completed"
47
+ payload = completed["result"]
48
+ assert payload
49
  assert payload["round_summary"]
50
 
51
  if expected_round < 3:
 
63
  return session_id
64
 
65
 
66
+ def _complete_session_mock(client):
67
+ start = client.post(
68
+ "/api/session/start",
69
+ json={
70
+ "goal": "Быстрый тест mock режима",
71
+ "topic": "Турнир по теннису",
72
+ "mock_mode": True,
73
+ },
74
+ )
75
+ assert start.status_code == 200
76
+ session = start.json()
77
+ assert session["mock_mode"] is True
78
+ session_id = session["session_id"]
79
+ questions = session["questions"]
80
+
81
+ for _expected_round in [1, 2, 3]:
82
+ mock_answers = client.post(f"/api/session/{session_id}/mock-answers")
83
+ assert mock_answers.status_code == 200
84
+ mock_payload = mock_answers.json()
85
+ assert len(mock_payload["answers"]) == 3
86
+
87
+ question_ids = [q["id"] for q in questions]
88
+ transcripts = [item["transcript"] for item in mock_payload["answers"]]
89
+ submit = client.post(
90
+ f"/api/session/{session_id}/submit",
91
+ json={"question_ids": ",".join(question_ids), "transcripts": transcripts},
92
+ )
93
+ assert submit.status_code == 200
94
+ accepted = submit.json()
95
+ completed = _wait_job_completed(client, accepted["job_id"])
96
+ assert completed["status"] == "completed"
97
+ result = completed["result"]
98
+ assert result
99
+ if result["is_complete"]:
100
+ break
101
+ questions = result["questions"]
102
+
103
+ return session_id
104
+
105
+
106
  def test_health(client):
107
  res = client.get("/health")
108
  assert res.status_code == 200
 
119
  results_payload = results.json()
120
  assert results_payload["is_complete"] is True
121
  assert len(results_payload["checklist"]) >= 1
122
+ assert len(results_payload["tool_insights"]) >= 1
123
  assert "Чеклист созвона" in results_payload["markdown"]
124
  assert results_payload["portrait"] is not None
125
  assert 1 <= results_payload["portrait"]["emotional_stability"] <= 10
 
148
  )
149
  assert res.status_code == 200
150
  assert "mock transcript" in res.json()["transcript"]
151
+
152
+
153
+ def test_mock_mode_autogenerated_answers_flow(client):
154
+ session_id = _complete_session_mock(client)
155
+ results = client.get(f"/api/session/{session_id}/results")
156
+ assert results.status_code == 200
157
+ payload = results.json()
158
+ assert payload["is_complete"] is True
159
+ assert len(payload["checklist"]) >= 1
tests/test_preflight_unittest.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
2
  import unittest
3
  import base64
 
4
 
5
  from fastapi.testclient import TestClient
6
 
@@ -21,6 +22,18 @@ def fake_webm_b64() -> str:
21
  return base64.b64encode(b"RIFF....FAKEAUDIO").decode("utf-8")
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  class PreflightFlowTest(unittest.TestCase):
25
  def setUp(self) -> None:
26
  self._client_cm = TestClient(app)
@@ -62,7 +75,11 @@ class PreflightFlowTest(unittest.TestCase):
62
  submit = self.client.post(f"/api/session/{session_id}/submit", json=payload)
63
 
64
  self.assertEqual(submit.status_code, 200)
65
- payload = submit.json()
 
 
 
 
66
  self.assertTrue(payload["round_summary"])
67
 
68
  if expected_round < 3:
@@ -82,6 +99,7 @@ class PreflightFlowTest(unittest.TestCase):
82
  results_payload = results.json()
83
  self.assertTrue(results_payload["is_complete"])
84
  self.assertGreaterEqual(len(results_payload["checklist"]), 1)
 
85
  self.assertIn("Чеклист созвона", results_payload["markdown"])
86
  self.assertIsNotNone(results_payload["portrait"])
87
  self.assertGreaterEqual(results_payload["portrait"]["emotional_stability"], 1)
 
1
  import os
2
  import unittest
3
  import base64
4
+ import time
5
 
6
  from fastapi.testclient import TestClient
7
 
 
22
  return base64.b64encode(b"RIFF....FAKEAUDIO").decode("utf-8")
23
 
24
 
25
+ def wait_job_completed(client, job_id: str, max_attempts: int = 300):
26
+ for _ in range(max_attempts):
27
+ status = client.get(f"/api/session/jobs/{job_id}")
28
+ if status.status_code != 200:
29
+ raise AssertionError(f"Failed to fetch job status for {job_id}")
30
+ payload = status.json()
31
+ if payload["status"] in {"completed", "failed"}:
32
+ return payload
33
+ time.sleep(0.01)
34
+ raise AssertionError("submit job did not finish in time")
35
+
36
+
37
  class PreflightFlowTest(unittest.TestCase):
38
  def setUp(self) -> None:
39
  self._client_cm = TestClient(app)
 
75
  submit = self.client.post(f"/api/session/{session_id}/submit", json=payload)
76
 
77
  self.assertEqual(submit.status_code, 200)
78
+ accepted = submit.json()
79
+ self.assertTrue(accepted["job_id"])
80
+ job_done = wait_job_completed(self.client, accepted["job_id"])
81
+ self.assertEqual(job_done["status"], "completed")
82
+ payload = job_done["result"]
83
  self.assertTrue(payload["round_summary"])
84
 
85
  if expected_round < 3:
 
99
  results_payload = results.json()
100
  self.assertTrue(results_payload["is_complete"])
101
  self.assertGreaterEqual(len(results_payload["checklist"]), 1)
102
+ self.assertGreaterEqual(len(results_payload["tool_insights"]), 1)
103
  self.assertIn("Чеклист созвона", results_payload["markdown"])
104
  self.assertIsNotNone(results_payload["portrait"])
105
  self.assertGreaterEqual(results_payload["portrait"]["emotional_stability"], 1)