Dropdead072 commited on
Commit
aacccb5
·
verified ·
1 Parent(s): f553b7c

Create pipeline.py

Browse files
Files changed (1) hide show
  1. pipeline.py +481 -0
pipeline.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TypedDict, List, Annotated
2
+ import json
3
+ import time
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+ import json, re
7
+ import tempfile
8
+ import zipfile, tarfile
9
+
10
+ from langchain_core.documents import Document
11
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
12
+ from langchain_community.vectorstores import FAISS
13
+ from langchain_huggingface import HuggingFaceEmbeddings
14
+ from langchain_community.llms.yandex import YandexGPT
15
+ from langgraph.graph import StateGraph, END
16
+ from langgraph.graph.message import add_messages
17
+
18
+ from dotenv import load_dotenv
19
+ load_dotenv()
20
+
21
+ from pathlib import Path
22
+ import os
23
+ from pypdf import PdfReader
24
+
25
+ # ====================== CONFIG ======================
26
+
27
+ YANDEX_API_KEY = os.getenv("YANDEX_API_KEY")
28
+ YANDEX_FOLDER_ID = os.getenv("YANDEX_FOLDER_ID")
29
+
30
+ # --- LLMs ---
31
+ llm_reasoning = YandexGPT(
32
+ api_key=YANDEX_API_KEY,
33
+ folder_id=YANDEX_FOLDER_ID,
34
+ temperature=0.3,
35
+ max_tokens=2000
36
+ )
37
+
38
+ llm_output = YandexGPT(
39
+ api_key=YANDEX_API_KEY,
40
+ folder_id=YANDEX_FOLDER_ID,
41
+ temperature=0.4,
42
+ max_tokens=6500
43
+ )
44
+
45
+ def llm_analyze(prompt: str) -> str:
46
+ return llm_reasoning.invoke(prompt)
47
+
48
+ def llm_final(prompt: str) -> str:
49
+ return llm_output.invoke(prompt)
50
+
51
+ # --- Logging ---
52
+ @contextmanager
53
+ def log_step(name: str):
54
+ print(f"\n>>> START: {name}")
55
+ start = time.time()
56
+ try:
57
+ yield
58
+ finally:
59
+ print(f"<<< END: {name} ({time.time() - start:.2f}s)")
60
+
61
+ # ====================== STATE ======================
62
+
63
+ class GraphState(TypedDict):
64
+ query: str
65
+ toc: str
66
+ toc_analysis: str
67
+ plan: dict
68
+ retrieval_queries: List[str]
69
+ contexts: List[Document]
70
+ result: dict
71
+ iteration: int
72
+ recurse: bool
73
+
74
+ # ====================== ARCHIVE PROCESSING ======================
75
+
76
+ def read_file(file_path: Path) -> str:
77
+ suffix = file_path.suffix.lower()
78
+
79
+ try:
80
+ # --- PDF ---
81
+ if suffix == ".pdf":
82
+ reader = PdfReader(str(file_path))
83
+ text = []
84
+ for page in reader.pages:
85
+ text.append(page.extract_text() or "")
86
+ return "\n".join(text)
87
+
88
+ # --- TXT / CODE ---
89
+ elif suffix in [".txt", ".md", ".py", ".json"]:
90
+ return file_path.read_text(encoding="utf-8", errors="ignore")
91
+
92
+ # --- fallback ---
93
+ else:
94
+ return file_path.read_text(encoding="utf-8", errors="ignore")
95
+
96
+ except Exception as e:
97
+ print(f"⚠️ Ошибка чтения {file_path}: {e}")
98
+ return ""
99
+
100
+
101
+ def extract_archive_to_documents(archive_path: str) -> List[Document]:
102
+ documents = []
103
+
104
+ with tempfile.TemporaryDirectory() as tmpdir:
105
+ tmp_path = Path(tmpdir)
106
+
107
+ if archive_path.endswith('.zip'):
108
+ with zipfile.ZipFile(archive_path) as z:
109
+ z.extractall(tmpdir)
110
+ elif archive_path.endswith(('.tar', '.tar.gz', '.tgz')):
111
+ with tarfile.open(archive_path) as t:
112
+ t.extractall(tmpdir)
113
+ else:
114
+ raise ValueError(f"Unsupported archive: {archive_path}")
115
+
116
+ for file_path in tmp_path.rglob("*"):
117
+ if file_path.is_file() and not file_path.name.startswith('.'):
118
+ try:
119
+ relative_path = file_path.relative_to(tmp_path)
120
+ text = read_file(file_path)
121
+
122
+ if not text.strip():
123
+ continue
124
+
125
+ doc = Document(
126
+ page_content=text,
127
+ metadata={
128
+ "source": str(relative_path),
129
+ "file_name": file_path.name,
130
+ "file_type": file_path.suffix.lower(),
131
+ "size_bytes": file_path.stat().st_size,
132
+ }
133
+ )
134
+ documents.append(doc)
135
+
136
+ except Exception as e:
137
+ print(f"⚠️ Не удалось обработать {file_path}: {e}")
138
+
139
+ print(f"Извлечено {len(documents)} документов из архива")
140
+ return documents
141
+
142
+
143
+ # ====================== VECTORSTORE ======================
144
+
145
+ def build_vectorstore(docs: List[Document]):
146
+ with log_step("Building FAISS Vectorstore"):
147
+ splitter = RecursiveCharacterTextSplitter(
148
+ chunk_size=1200,
149
+ chunk_overlap=150,
150
+ separators=["\n\n", "\n", ". ", " ", ""]
151
+ )
152
+
153
+ splits = splitter.split_documents(docs)
154
+
155
+ embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
156
+
157
+ db = FAISS.from_documents(splits, embeddings)
158
+ retriever = db.as_retriever(
159
+ search_type="mmr",
160
+ search_kwargs={"k": 10, "fetch_k": 25, "lambda_mult": 0.7}
161
+ )
162
+ return db, retriever
163
+
164
+ # ====================== NODES ======================
165
+
166
+ def analyze_toc(state: GraphState):
167
+ with log_step("analyze_toc"):
168
+ prompt = f"Оглавление курса:\n{state['toc']}\n\nКратко опиши структуру курса, выделив основные разделы и логику."
169
+ toc_analysis = llm_analyze(prompt)
170
+ return {"toc_analysis": toc_analysis}
171
+
172
+
173
+ def planner(state: GraphState):
174
+ with log_step("planner"):
175
+ prompt = f"""
176
+ Оглавление:
177
+ {state['toc']}
178
+
179
+ Анализ структуры:
180
+ {state['toc_analysis']}
181
+
182
+ Запрос пользователя:
183
+ {state['query']}
184
+
185
+ Составь план retrieval'а. Верни JSON:
186
+ {{
187
+ "sections": ["список ключевых тем"],
188
+ "queries": ["конкретные поисковые запросы для RAG"]
189
+ }}
190
+ """
191
+ raw = llm_analyze(prompt)
192
+ try:
193
+ plan = json.loads(raw)
194
+ except:
195
+ plan = {"sections": [], "queries": [state["query"]]}
196
+
197
+ return {
198
+ "plan": plan,
199
+ "retrieval_queries": plan.get("queries", [state["query"]]),
200
+ "iteration": 0
201
+ }
202
+
203
+
204
+ def retrieve(state: GraphState):
205
+ with log_step("retrieve"):
206
+ all_docs = []
207
+ for q in state["retrieval_queries"]:
208
+ docs = retriever.invoke(q)
209
+ all_docs.extend(docs)
210
+
211
+ seen = set()
212
+ unique_docs = []
213
+ for doc in all_docs:
214
+ content_hash = hash(doc.page_content[:200])
215
+ if content_hash not in seen:
216
+ seen.add(content_hash)
217
+ unique_docs.append(doc)
218
+
219
+ print(f"Retrieved {len(unique_docs)} unique documents")
220
+ return {
221
+ "contexts": state["contexts"] + unique_docs
222
+ }
223
+
224
+ def check_completeness(state: GraphState):
225
+ with log_step("check_completeness"):
226
+ state["iteration"] += 1
227
+
228
+ context_text = "\n\n".join(doc.page_content for doc in state["contexts"])[:6000]
229
+
230
+ prompt = f"""
231
+ Запрос пользователя: {state['query']}
232
+
233
+ Текущий контекст имеет {len(state['contexts'])} документов.
234
+
235
+ Проанализируй, достаточно ли материала для создания полноценного 9-недельного интенсивного курса.
236
+
237
+ Верни JSON:
238
+ {{
239
+ "enough": true или false,
240
+ "next_query": "если не enough — один хороший поисковый запрос, иначе пустая строка"
241
+ }}
242
+ """
243
+
244
+ raw = llm_analyze(prompt)
245
+
246
+ try:
247
+ match = re.search(r'\{[\s\S]*\}', raw)
248
+ data = json.loads(match.group(0)) if match else json.loads(raw)
249
+
250
+ enough = data.get("enough", False)
251
+ next_query = data.get("next_query", "").strip()
252
+
253
+ print(f"Enough: {enough}")
254
+ if next_query:
255
+ print(f"→ New query: {next_query}")
256
+
257
+ if not enough and next_query and state["iteration"] < 4:
258
+ return {
259
+ "retrieval_queries": state["retrieval_queries"] + [next_query],
260
+ "recurse": True
261
+ }
262
+ else:
263
+ return {"recurse": False}
264
+
265
+ except Exception as e:
266
+ print(f"Parse error in check_completeness: {e}")
267
+ return {"recurse": False}
268
+
269
+
270
+ def generate_weekly_plan(state: GraphState):
271
+ with log_step("generate_weekly_plan"):
272
+
273
+ context_blocks = []
274
+ for doc in state["contexts"][:25]:
275
+ meta = doc.metadata
276
+ source = meta.get("source") or meta.get("file_name", "unknown")
277
+ preview = doc.page_content[:700].replace("\n", " ").strip()
278
+ context_blocks.append(f"Источник → {source}\n{preview}...\n")
279
+
280
+ context_text = "\n\n" + "-" * 80 + "\n\n".join(context_blocks)
281
+
282
+ prompt = f"""Ты — строгий профессиональный методист.
283
+ Отвечай **исключительно валидным JSON**, без единого слова вне объекта.
284
+
285
+ Запрос пользователя: {state['query']}
286
+
287
+ Оглавление курса:
288
+ {state.get('toc', 'Не предоставлено')}
289
+
290
+ Доступные материалы:
291
+ {context_text}
292
+
293
+ Создай понедельный учебный план.
294
+ В одной неделе может быть от 2 до 5 занятий.
295
+
296
+ Если требуется усиленный курс, то надо от 3х занятий в неделю.
297
+
298
+ Верни **ТОЛЬКО** этот JSON (начинай сразу с '{{'):
299
+
300
+ {{
301
+ "course_title": "Название курса",
302
+ "duration_weeks": число,
303
+ "weekly_plan": [
304
+ {{
305
+ "week": 1,
306
+ "theme": "Общая тема недели",
307
+ "sessions": [
308
+ {{
309
+ "session_number": 1,
310
+ "title": "Название конкретного занятия",
311
+ "goal": "Подробная мотивировка и цель занятия (что студент должен понять и уметь)",
312
+ "main_sources": [
313
+ {{
314
+ "material": "Название учебника или лекции",
315
+ "chapter": "§1 Проективное пространство",
316
+ "section": "1.1 Соглашения об обозначениях, 1.2 Определение",
317
+ "file_source": "имя_файла.pdf"
318
+ }}
319
+ ],
320
+ "preparation_materials": ["список строк для подготовки"],
321
+ "practice_and_homework": ["конкретные задания, задачи, домашняя работа"],
322
+ "estimated_time": "3–5 часов"
323
+ }}
324
+ ]
325
+ }}
326
+ ],
327
+ "additional_recommendations": "Общие рекомендации"
328
+ }}
329
+
330
+ Правила, которых нужно строго придерживаться:
331
+ - Используй реальные названия глав, параграфов и файлов из предоставленных материалов.
332
+ - Обязательно заполняй поле "goal" — подробная мотивировка, зачем это занятие нужно.
333
+ - Поле "file_source" должно содержать реальное имя файла из метаданных, если возможно.
334
+ - В одной неделе можно делать несколько занятий (sessions).
335
+ - Не пиши ничего кроме JSON.
336
+ """
337
+
338
+ raw = llm_final(prompt)
339
+
340
+ try:
341
+ cleaned = raw.strip()
342
+ if cleaned.startswith("```"):
343
+ cleaned = cleaned.split("```")[1].strip()
344
+ if cleaned.lower().startswith(("json", "```json")):
345
+ cleaned = cleaned.split("\n", 1)[-1].strip()
346
+
347
+ plan = json.loads(cleaned)
348
+
349
+ except json.JSONDecodeError:
350
+ import re
351
+ match = re.search(r'(\{[\s\S]*\})', raw, re.DOTALL)
352
+ if match:
353
+ try:
354
+ plan = json.loads(match.group(1))
355
+ except:
356
+ plan = {"error": "JSON parse failed", "raw_preview": raw[:1000]}
357
+ else:
358
+ plan = {"error": "JSON parse failed", "raw_preview": raw[:800]}
359
+
360
+
361
+ return {"result": plan}
362
+
363
+
364
+ def make_final_output_pretty(state: GraphState):
365
+ import json
366
+
367
+ raw_plan = state["result"]
368
+
369
+ if isinstance(raw_plan, dict):
370
+ plan_str = json.dumps(raw_plan, ensure_ascii=False, indent=2)
371
+ else:
372
+ plan_str = str(raw_plan)
373
+
374
+ prompt = f"""
375
+ Ты — отличный технический писатель и методист.
376
+
377
+ У тебя есть следующий JSON с учебным планом (возможно, неидеальный):
378
+
379
+ {plan_str}
380
+
381
+ Твоя задача:
382
+ преобразовать его в КРАСИВЫЙ и СТРУКТУРИРОВАННЫЙ Markdown-документ.
383
+
384
+ Требования:
385
+ - Используй заголовки (#, ##, ###)
386
+ - Выделяй недели как отдельные секции
387
+ - Для каждой недели:
388
+ - тема
389
+ - список занятий (bullets)
390
+ - краткое описание (если есть)
391
+ - Добавь читаемую структуру курса
392
+ - В конце добавь раздел "Рекомендации"
393
+ - Убери технический мусор и JSON-структуру
394
+ - Сделай текст естественным, как учебный материал
395
+
396
+ Формат:
397
+ # Название курса
398
+
399
+ ## Неделя 1 — Тема
400
+ - Занятие 1
401
+ - Занятие 2
402
+
403
+ ## Неделя 2 — ...
404
+
405
+ ## Рекомендации
406
+
407
+ Верни ТОЛЬКО Markdown, без JSON и без пояснений.
408
+ """
409
+
410
+ pretty = llm_final(prompt)
411
+
412
+ return {"result": pretty}
413
+
414
+
415
+ # ====================== GRAPH ======================
416
+ def should_continue(state: GraphState) -> str:
417
+ if state.get("recurse") is True:
418
+ return "retrieve"
419
+ return "generate_weekly_plan"
420
+
421
+
422
+ builder = StateGraph(GraphState)
423
+
424
+ builder.add_node("analyze_toc", analyze_toc)
425
+ builder.add_node("planner", planner)
426
+ builder.add_node("retrieve", retrieve)
427
+ builder.add_node("check_completeness", check_completeness)
428
+ builder.add_node("generate_weekly_plan", generate_weekly_plan)
429
+ builder.add_node("pretty_output", make_final_output_pretty)
430
+
431
+ builder.set_entry_point("analyze_toc")
432
+
433
+ builder.add_edge("analyze_toc", "planner")
434
+ builder.add_edge("planner", "retrieve")
435
+ builder.add_edge("retrieve", "check_completeness")
436
+
437
+ builder.add_conditional_edges(
438
+ "check_completeness",
439
+ should_continue,
440
+ {
441
+ "retrieve": "retrieve",
442
+ "generate_weekly_plan": "generate_weekly_plan"
443
+ }
444
+ )
445
+
446
+ builder.add_edge("generate_weekly_plan", "pretty_output")
447
+ builder.add_edge("pretty_output", END)
448
+
449
+ graph = builder.compile()
450
+
451
+ # ====================== GLOBAL ======================
452
+ retriever = None
453
+ toc_text = ""
454
+
455
+ # ====================== MAIN PIPELINE ======================
456
+
457
+ def run_course_builder(query: str, toc: str, archive_path: str):
458
+ global retriever, toc_text
459
+
460
+ toc_text = toc
461
+
462
+ documents = extract_archive_to_documents(archive_path)
463
+
464
+ _, retriever = build_vectorstore(documents)
465
+
466
+ initial_state = {
467
+ "query": query,
468
+ "toc": toc_text,
469
+ "toc_analysis": "",
470
+ "plan": {},
471
+ "retrieval_queries": [],
472
+ "contexts": [],
473
+ "result": {},
474
+ "iteration": 0,
475
+ "recurse": False
476
+ }
477
+
478
+ final_state = graph.invoke(initial_state)
479
+
480
+ return final_state["result"]
481
+