From 9addf4d201a2b9473de027bb576b26a73a6a3543 Mon Sep 17 00:00:00 2001 From: youkyeom Date: Tue, 4 Aug 2026 15:49:18 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20AI=20=EB=84=A4=ED=8A=B8?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=20=ED=98=B8=EC=B6=9C=EC=9D=B4=20transaction.?= =?UTF-8?q?atomic=20=EB=82=B4=EB=B6=80=EC=97=90=EC=84=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- exams/services/analysis_orchestrator.py | 51 ++++++++++++---- exams/services/task_extractor.py | 79 ++++++++++++++++++------- exams/tests.py | 71 ++++++++++++++++++++-- 3 files changed, 161 insertions(+), 40 deletions(-) diff --git a/exams/services/analysis_orchestrator.py b/exams/services/analysis_orchestrator.py index 6c04118..918aac9 100644 --- a/exams/services/analysis_orchestrator.py +++ b/exams/services/analysis_orchestrator.py @@ -46,11 +46,12 @@ - 그 외 예기치 못한 예외: 상세 내용은 로그에만 남기고, 사용자용 메시지는 일반적인 문구로 저장 (내부 구현 노출 방지) -알려진 한계 (이번 PR 범위 밖, 후속 리팩터링 이슈로 분리): -- task_extractor.analyze_study_material()가 @transaction.atomic이라, 그 안에서 - 이뤄지는 AI 네트워크 호출이 DB 트랜잭션을 물고 있는 상태로 실행된다. - 외부 네트워크 호출을 트랜잭션 안에 두는 것은 이상적이지 않지만, task_extractor - 구조 자체를 바꿔야 하는 사안이라 이 PR에서는 다루지 않는다. +해결된 이슈: +- (과거) task_extractor.analyze_study_material()가 @transaction.atomic이라 그 안의 + AI 네트워크 호출이 DB 트랜잭션을 물고 있었음 -> task_extractor를 + fetch_extracted_tasks()(네트워크, 트랜잭션 없음)와 save_extracted_tasks()(DB 쓰기, + 짧은 트랜잭션)로 분리했고, 이 파일도 analyze_study_material() 대신 + fetch_extracted_tasks()를 직접 호출해서 AI 호출이 트랜잭션 밖에서 실행되도록 함. """ from __future__ import annotations @@ -62,7 +63,7 @@ from core.choices import MaterialStatus from core.exceptions import AIAnalysisError, AIResponseValidationError from exams.models import StudyMaterial, StudyTask -from exams.services.task_extractor import analyze_study_material +from exams.services.task_extractor import fetch_extracted_tasks, save_extracted_tasks from planner.services.time_estimator import estimate_task_minutes logger = logging.getLogger(__name__) @@ -96,21 +97,45 @@ class AnalysisPipelineError(Exception): """ -@transaction.atomic def _run_analysis_and_estimate(study_material: StudyMaterial) -> list[StudyTask]: """ AI 분석(task_extractor)과 예상시간 계산(time_estimator)을 순서대로 실행한다. analysis_status는 건드리지 않는다 (상태 관리는 호출하는 쪽이 담당). 처리 순서: - 1. analyze_study_material()로 StudyTask 생성 (estimated_min/max_minutes=0) - 2. 생성된 각 StudyTask에 대해 BE1의 estimate_task_minutes() 호출 - 3. 계산된 예상시간을 한 번에 bulk_update로 반영 + 1. fetch_extracted_tasks()로 AI 호출 + 파싱 + 검증 (네트워크, 트랜잭션 없음) + 2. _save_tasks_with_estimates()로 StudyTask 생성과 예상시간 계산을 + 하나의 짧은 트랜잭션으로 저장 (DB 쓰기만 있어서 커넥션을 오래 안 붙잡음) + + 이렇게 나눈 이유: AI 네트워크 호출은 재시도 포함 최대 수십 초가 걸릴 수 있는데, + 이걸 DB 트랜잭션 안에 두면 그동안 커넥션을 계속 점유하게 된다. 네트워크 호출을 + 트랜잭션 밖으로 완전히 빼서, DB 트랜잭션은 실제 DB 쓰기 구간(순식간에 끝남)만 + 감싸도록 했다. + + 실패 시: + - 1단계(AI 호출) 실패: 애초에 트랜잭션이 시작도 안 되므로 DB에는 아무 + 변화도 없다. + - 2단계(DB 저장) 실패: _save_tasks_with_estimates()가 @transaction.atomic이므로 + 그 안에서 생성된 StudyTask도 함께 롤백된다. "예상시간 없는 StudyTask"가 + DB에 남지 않는다. + """ + if not study_material.extracted_text: + raise AIResponseValidationError("StudyMaterial에 분석할 텍스트가 없습니다.") + + exam = study_material.exam + extracted_tasks = fetch_extracted_tasks(exam, study_material.extracted_text) - 실패 시 이 함수 전체가 @transaction.atomic이므로 생성된 StudyTask도 함께 - 롤백된다. "예상시간 없는 StudyTask"가 DB에 남지 않는다. + return _save_tasks_with_estimates(study_material, extracted_tasks) + + +@transaction.atomic +def _save_tasks_with_estimates(study_material: StudyMaterial, extracted_tasks) -> list[StudyTask]: + """ + AI가 추출한 결과를 StudyTask로 저장하고, 곧바로 예상시간까지 채운다. + DB 쓰기만 하고 네트워크 호출은 전혀 없어서, 트랜잭션으로 묶어도 커넥션을 + 오래 점유하지 않는다. """ - tasks = analyze_study_material(study_material) + tasks = save_extracted_tasks(study_material, extracted_tasks) if not tasks: return tasks diff --git a/exams/services/task_extractor.py b/exams/services/task_extractor.py index a978b02..851535c 100644 --- a/exams/services/task_extractor.py +++ b/exams/services/task_extractor.py @@ -280,31 +280,21 @@ def _parse_and_validate(raw_response: str) -> list[ExtractedTask]: return tasks -@transaction.atomic -def analyze_study_material(study_material: StudyMaterial) -> list[StudyTask]: +def fetch_extracted_tasks(exam: Exam, extracted_text: str) -> list[ExtractedTask]: """ - StudyMaterial의 추출된 텍스트를 AI로 분석해 StudyTask들을 생성한다. + AI 호출 + 파싱 + 검증만 수행한다. DB 접근이 전혀 없고, 트랜잭션도 걸지 않는다. - - AI가 생성하는 값: unit_name, title, task_type, importance, depth, difficulty, ai_reason - - AI가 생성하지 않는 값: estimated_min/max_minutes - -> 0으로 남겨두고, 이후 BE1의 time_estimator 서비스가 채운다. - - 생성된 StudyTask는 is_confirmed=False, is_user_modified=False 상태로 저장된다. - 사용자가 검토/수정/확정하기 전까지는 최종 계획에 사용되지 않는다. - - AI 응답이 JSON 검증에 실패하면, 실패 이유를 프롬프트에 덧붙여 최대 - MAX_VALIDATION_RETRIES회 self-correction 재요청을 한다. - - 동일 StudyMaterial에 대해 다시 실행되면(재분석), 이전에 생성된 미확정·미수정 - StudyTask는 삭제하고 새로 만든다 (사용자가 수정했거나 확정한 작업은 보존). + (기존 이슈: analyze_study_material() 전체가 @transaction.atomic이라, 그 안에서 + 벌어지는 AI 네트워크 호출(재시도 포함 최대 수십 초 소요 가능)이 DB 커넥션을 + 오래 점유하는 문제가 있었다. 이 함수는 순수 네트워크/파싱만 담당해서 DB 트랜잭션과 + 완전히 분리한다 - 호출하는 쪽에서 결과를 받은 뒤 별도로, 가능한 한 짧게 DB에 저장해야 한다.) + + AI 응답이 JSON 검증에 실패하면, 실패 이유를 프롬프트에 덧붙여 최대 + MAX_VALIDATION_RETRIES회 self-correction 재요청을 한다. 실패 시 AIAnalysisError 계열 예외(AICallFailedError, AIResponseValidationError)를 - 발생시키며, 이 경우 StudyTask는 생성/삭제되지 않는다 (트랜잭션 롤백). + 발생시킨다. """ - if not study_material.extracted_text: - raise AIResponseValidationError("StudyMaterial에 분석할 텍스트가 없습니다.") - - exam: Exam = study_material.exam - - # AI 응답이 검증에 실패하면, 실패 이유를 프롬프트에 덧붙여 최대 MAX_VALIDATION_RETRIES회 - # 재요청한다 (self-correction). 그래도 실패하면 AIResponseValidationError를 던진다. extracted_tasks = None last_validation_error: str | None = None @@ -312,7 +302,7 @@ def analyze_study_material(study_material: StudyMaterial) -> list[StudyTask]: prompt = build_prompt( exam.subject_name, exam.exam_date, - study_material.extracted_text, + extracted_text, previous_error=last_validation_error, ) raw_response = _call_ai(prompt) @@ -331,6 +321,24 @@ def analyze_study_material(study_material: StudyMaterial) -> list[StudyTask]: f"AI 응답 검증이 {MAX_VALIDATION_RETRIES + 1}회 모두 실패했습니다: {last_validation_error}" ) + return extracted_tasks + + +@transaction.atomic +def save_extracted_tasks( + study_material: StudyMaterial, extracted_tasks: list[ExtractedTask] +) -> list[StudyTask]: + """ + fetch_extracted_tasks()가 만든 결과를 StudyTask로 저장한다. DB 쓰기 전용이라 + 네트워크 호출 없이 짧게 끝나므로, 트랜잭션으로 묶어도 DB 커넥션을 오래 점유하지 않는다. + + - 생성된 StudyTask는 is_confirmed=False, is_user_modified=False 상태로 저장된다. + 사용자가 검토/수정/확정하기 전까지는 최종 계획에 사용되지 않는다. + - 동일 StudyMaterial에 대해 다시 실행되면(재분석), 이전에 생성된 미확정·미수정 + StudyTask는 삭제하고 새로 만든다 (사용자가 수정했거나 확정한 작업은 보존). + """ + exam: Exam = study_material.exam + # 동일 StudyMaterial로 "AI 분석 다시 실행"을 하는 경우, 이전에 생성된 미확정/미수정 # StudyTask가 계속 누적되는 것을 방지하기 위해 먼저 정리한다. # (사용자가 직접 수정했거나(is_user_modified) 확정한(is_confirmed) 작업은 건드리지 않는다) @@ -373,4 +381,31 @@ def analyze_study_material(study_material: StudyMaterial) -> list[StudyTask]: # 추후 필드가 늘어나면 이 부분을 다시 확인할 것. StudyTask.objects.bulk_create(created_tasks) logger.info("AI 분석 완료: exam=%s, 생성된 작업 %d개", exam.subject_name, len(created_tasks)) - return created_tasks \ No newline at end of file + return created_tasks + + +def analyze_study_material(study_material: StudyMaterial) -> list[StudyTask]: + """ + StudyMaterial의 추출된 텍스트를 AI로 분석해 StudyTask들을 생성한다. + (fetch_extracted_tasks + save_extracted_tasks를 순서대로 호출하는 편의 함수) + + - AI가 생성하는 값: unit_name, title, task_type, importance, depth, difficulty, ai_reason + - AI가 생성하지 않는 값: estimated_min/max_minutes + -> 0으로 남겨두고, 이후 BE1의 time_estimator 서비스가 채운다. + + 주의: 이 함수 자체는 트랜잭션으로 감싸져 있지 않다 (AI 네트워크 호출을 트랜잭션 + 밖에 두기 위함). DB 쓰기는 save_extracted_tasks() 안에서만 짧게 트랜잭션 처리된다. + 이 함수를 다른 DB 작업과 원자적으로(atomic) 묶어야 하는 경우(예: 예상시간 계산까지 + 한 번에 롤백되어야 하는 경우)에는, 이 함수 대신 fetch_extracted_tasks()로 AI 결과를 + 먼저 받아온 뒤, 필요한 DB 작업들을 직접 하나의 @transaction.atomic으로 묶을 것 + (analysis_orchestrator.py의 _run_analysis_and_estimate() 참고). + + 실패 시 AIAnalysisError 계열 예외(AICallFailedError, AIResponseValidationError)를 + 발생시키며, 이 경우 StudyTask는 생성/삭제되지 않는다. + """ + if not study_material.extracted_text: + raise AIResponseValidationError("StudyMaterial에 분석할 텍스트가 없습니다.") + + exam: Exam = study_material.exam + extracted_tasks = fetch_extracted_tasks(exam, study_material.extracted_text) + return save_extracted_tasks(study_material, extracted_tasks) \ No newline at end of file diff --git a/exams/tests.py b/exams/tests.py index 541fd8a..1baa6c9 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -3,7 +3,8 @@ import pypdf from unittest.mock import patch -from django.test import TestCase +from django.db import connection +from django.test import TestCase, TransactionTestCase from django.urls import reverse from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile @@ -29,6 +30,7 @@ ) from django.core.files.uploadedfile import SimpleUploadedFile +from exams.services import task_extractor from exams.services.pdf_extractor import extract_text_from_pdf, PdfExtractionError User = get_user_model() @@ -406,7 +408,7 @@ def test_initial_analysis_does_not_touch_extraction_status_fields(self): self.assertEqual(material.status, MaterialStatus.COMPLETED) self.assertIsNone(material.error_message) - @patch("exams.services.analysis_orchestrator.analyze_study_material") + @patch("exams.services.analysis_orchestrator.fetch_extracted_tasks") def test_ai_analysis_failure_sets_failed_and_rolls_back(self, mock_analyze): mock_analyze.side_effect = AICallFailedError("AI 서버 연결 실패") material = self._make_material() @@ -435,7 +437,7 @@ def test_time_estimation_failure_sets_failed_and_rolls_back(self, mock_estimate) self.assertNotIn("예상시간 계산 중 알 수 없는 오류", material.analysis_error_message or "") self.assertEqual(StudyTask.objects.filter(study_material=material).count(), 0) - @patch("exams.services.analysis_orchestrator.analyze_study_material") + @patch("exams.services.analysis_orchestrator.fetch_extracted_tasks") def test_empty_result_is_treated_as_failure(self, mock_analyze): mock_analyze.return_value = [] material = self._make_material() @@ -484,7 +486,7 @@ def test_second_retry_succeeds_after_first_retry_fails(self): material.save(update_fields=["analysis_status", "analysis_retry_count"]) # 1차 재시도는 실패시킨다 - with patch("exams.services.analysis_orchestrator.analyze_study_material") as mock_analyze: + with patch("exams.services.analysis_orchestrator.fetch_extracted_tasks") as mock_analyze: mock_analyze.side_effect = AICallFailedError("1차 재시도 실패") with self.assertRaises(AICallFailedError): retry_analysis(material) @@ -819,4 +821,63 @@ def test_extract_text_from_empty_pdf_or_image(self): with self.assertRaises(PdfExtractionError) as context: extract_text_from_pdf(dummy_file) - self.assertIn("PDF에서 텍스트를 추출할 수 없습니다", str(context.exception)) \ No newline at end of file + self.assertIn("PDF에서 텍스트를 추출할 수 없습니다", str(context.exception)) + +class AITransactionIsolationTestCase(TransactionTestCase): + """ + 이슈: task_extractor.analyze_study_material()가 통째로 @transaction.atomic이라, + 그 안에서 벌어지는 AI 네트워크 호출이 DB 트랜잭션을 물고 있는 채로 실행되던 문제. + + fetch_extracted_tasks()(네트워크, 트랜잭션 없음)와 save_extracted_tasks()/ + _save_tasks_with_estimates()(DB 쓰기, 짧은 트랜잭션)로 분리한 뒤, + AI 호출 시점에 실제로 열려있는 DB 트랜잭션이 없는지 직접 검증한다. + + TestCase가 아니라 TransactionTestCase를 쓰는 이유: 일반 TestCase는 테스트 + 하나하나를 자체적으로 큰 트랜잭션으로 감싸서 롤백하기 때문에, 그 안에서는 + connection.in_atomic_block이 항상 True로 나와 이 검증 자체가 무의미해진다. + """ + + def setUp(self): + self.user = User.objects.create_user( + username="tx_tester@example.com", email="tx_tester@example.com", password="pass1234!" + ) + self.period = ExamPeriod.objects.create( + user=self.user, title="트랜잭션 격리 테스트", + start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2026, 8, 20), + ) + self.exam = Exam.objects.create( + exam_period=self.period, subject_name="테스트과목", exam_date=datetime.date(2026, 8, 18), + ) + self.material = StudyMaterial.objects.create( + exam=self.exam, title="테스트 자료", extracted_text="1장 개념 정리", + ) + + def test_call_ai_runs_without_open_transaction(self): + observed_in_atomic_block = [] + + original_call_ai = task_extractor._call_ai + + def spy_call_ai(prompt): + observed_in_atomic_block.append(connection.in_atomic_block) + return original_call_ai(prompt) + + with patch("exams.services.task_extractor._call_ai", side_effect=spy_call_ai): + analyze_and_estimate(self.material) + + self.assertEqual(len(observed_in_atomic_block), 1) + self.assertFalse( + observed_in_atomic_block[0], + "AI 네트워크 호출(_call_ai) 시점에 DB 트랜잭션이 열려있으면 안 된다.", + ) + + def test_studytask_creation_still_rolls_back_on_db_failure(self): + """ + 네트워크 호출을 트랜잭션 밖으로 뺐어도, DB 저장 단계 자체의 원자성은 + 여전히 보장되어야 한다 (StudyTask 생성 + 예상시간 반영이 한 단위로 롤백). + """ + with patch("exams.services.analysis_orchestrator.estimate_task_minutes") as mock_estimate: + mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") + with self.assertRaises(AnalysisPipelineError): + analyze_and_estimate(self.material) + + self.assertEqual(StudyTask.objects.filter(study_material=self.material).count(), 0) \ No newline at end of file From 01c40d2a7cd303f05f54e2e587c2fbb213de53e3 Mon Sep 17 00:00:00 2001 From: youkyeom Date: Tue, 4 Aug 2026 17:37:02 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20AI=20=ED=98=B8=EC=B6=9C-=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=82=AC=EC=9D=B4=20=EC=9E=85=EB=A0=A5=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EA=B2=BD=EC=9F=81=20=EC=83=81=ED=83=9C=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84=20(#47=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- exams/services/analysis_orchestrator.py | 79 ++++++++++++++++++++-- exams/tests.py | 88 +++++++++++++++++++++++-- 2 files changed, 157 insertions(+), 10 deletions(-) diff --git a/exams/services/analysis_orchestrator.py b/exams/services/analysis_orchestrator.py index 918aac9..92047f4 100644 --- a/exams/services/analysis_orchestrator.py +++ b/exams/services/analysis_orchestrator.py @@ -52,6 +52,17 @@ fetch_extracted_tasks()(네트워크, 트랜잭션 없음)와 save_extracted_tasks()(DB 쓰기, 짧은 트랜잭션)로 분리했고, 이 파일도 analyze_study_material() 대신 fetch_extracted_tasks()를 직접 호출해서 AI 호출이 트랜잭션 밖에서 실행되도록 함. + +AI 호출과 DB 저장 사이의 입력 변경 경쟁 상태 (리뷰 반영): +- AI 네트워크 호출을 트랜잭션 밖으로 뺀 대가로, "AI 호출 시작 ~ 결과 저장" 사이에 + StudyMaterial이 바뀔 수 있는 창(window)이 생겼다 (예: 다른 요청이 PDF를 다시 + 추출해서 extracted_text가 바뀌는 경우). 이 경우를 대비해 _save_tasks_with_estimates()가 + 저장 직전에 StudyMaterial을 다시 조회해서 다음을 재검증한다. + 1. analysis_status가 여전히 PROCESSING인지 + 2. extracted_text가 AI 호출 당시와 동일한지 (analyzed_text로 전달받아 비교) + 둘 중 하나라도 달라졌으면 StaleAnalysisRequestError를 던지고 아무것도 저장하지 않는다. +- 같은 이유로 예상시간 계산에 쓰는 exam.speed_factor도, AI 호출 전에 로드해둔 오래된 + 객체가 아니라 저장 시점에 다시 조회한 최신 값을 사용한다. """ from __future__ import annotations @@ -83,6 +94,15 @@ class AnalysisNotSupportedError(Exception): """COMPLETED 상태처럼, 정책상 이 상태에서는 (재)분석을 지원하지 않을 때""" +class StaleAnalysisRequestError(Exception): + """ + AI 호출 시작 이후 저장 시점까지 사이에 StudyMaterial이 바뀌어서 + (analysis_status가 더 이상 PROCESSING이 아니거나, extracted_text가 AI 호출 + 당시와 달라져서) 지금 들고 있는 AI 결과를 더 이상 신뢰할 수 없을 때. + 이 경우 결과를 저장하지 않고 조용히 포기한다. + """ + + class AnalysisPipelineError(Exception): """ 분석 파이프라인(task_extractor/time_estimator/bulk_update 등)에서 @@ -112,6 +132,10 @@ def _run_analysis_and_estimate(study_material: StudyMaterial) -> list[StudyTask] 트랜잭션 밖으로 완전히 빼서, DB 트랜잭션은 실제 DB 쓰기 구간(순식간에 끝남)만 감싸도록 했다. + AI 호출에 쓴 텍스트(analyzed_text)를 저장 단계까지 그대로 들고 가서, 저장 + 시점에 StudyMaterial.extracted_text와 비교한다 (아래 _save_tasks_with_estimates + 참고) - 그 사이에 텍스트가 바뀌었으면 이 결과는 버려야 하기 때문이다. + 실패 시: - 1단계(AI 호출) 실패: 애초에 트랜잭션이 시작도 안 되므로 DB에는 아무 변화도 없다. @@ -123,24 +147,57 @@ def _run_analysis_and_estimate(study_material: StudyMaterial) -> list[StudyTask] raise AIResponseValidationError("StudyMaterial에 분석할 텍스트가 없습니다.") exam = study_material.exam - extracted_tasks = fetch_extracted_tasks(exam, study_material.extracted_text) + analyzed_text = study_material.extracted_text + extracted_tasks = fetch_extracted_tasks(exam, analyzed_text) - return _save_tasks_with_estimates(study_material, extracted_tasks) + return _save_tasks_with_estimates(study_material, extracted_tasks, analyzed_text) @transaction.atomic -def _save_tasks_with_estimates(study_material: StudyMaterial, extracted_tasks) -> list[StudyTask]: +def _save_tasks_with_estimates( + study_material: StudyMaterial, extracted_tasks, analyzed_text: str +) -> list[StudyTask]: """ AI가 추출한 결과를 StudyTask로 저장하고, 곧바로 예상시간까지 채운다. DB 쓰기만 하고 네트워크 호출은 전혀 없어서, 트랜잭션으로 묶어도 커넥션을 오래 점유하지 않는다. + + 저장 직전에 StudyMaterial을 다시 조회해서(select_for_update로 잠그면서), + AI 호출 이후 상태가 바뀌지 않았는지 재검증한다: + - analysis_status가 여전히 PROCESSING인지 + - extracted_text가 analyzed_text(AI 호출에 실제로 쓴 텍스트)와 같은지 + 둘 중 하나라도 어긋나면 StaleAnalysisRequestError를 던지고 아무것도 쓰지 않는다. + (select_for_update는 SQLite에서 실제 잠금이 걸리지는 않지만, 재조회 자체는 + 트랜잭션 안에서 최신 값을 가져오므로 최소한의 방어 역할은 한다.) + + 예상시간 계산에 쓰는 exam.speed_factor도 AI 호출 전에 로드해둔 오래된 객체가 + 아니라, 이 재조회로 얻은 최신 값을 사용한다. """ - tasks = save_extracted_tasks(study_material, extracted_tasks) + current = ( + StudyMaterial.objects + .select_for_update() + .select_related("exam") + .get(pk=study_material.pk) + ) + + if current.analysis_status != MaterialStatus.PROCESSING: + raise StaleAnalysisRequestError( + f"저장 시점에 analysis_status가 PROCESSING이 아닙니다 " + f"(현재: {current.analysis_status}). study_material_id={study_material.pk}" + ) + + if current.extracted_text != analyzed_text: + raise StaleAnalysisRequestError( + f"저장 시점에 추출 텍스트가 AI 호출 당시와 달라 결과를 저장하지 않습니다. " + f"study_material_id={study_material.pk}" + ) + + tasks = save_extracted_tasks(current, extracted_tasks) if not tasks: return tasks - exam = study_material.exam + exam = current.exam # 재조회로 얻은 최신 exam (speed_factor 최신값 보장) for task in tasks: est_min, est_max = estimate_task_minutes( task_type=task.task_type, @@ -188,6 +245,18 @@ def _execute_analysis(study_material: StudyMaterial) -> list[StudyTask]: """ try: tasks = _run_analysis_and_estimate(study_material) + except StaleAnalysisRequestError as exc: + # 이 요청은 여전히 PROCESSING의 유일한 소유자다 (_start_processing의 조건부 + # UPDATE 덕분에 동시에 두 실행이 PROCESSING을 가질 수 없음 - 이 예외는 + # "저장 시점에 입력이 바뀌었다"는 뜻이지 "다른 실행에게 뺏겼다"는 뜻이 아니다). + # 그래서 안전하게 FAILED로 마무리해 사용자가 다시 시도할 수 있게 한다. + message = "분석 도중 자료 내용이 변경되어 결과를 저장하지 않았습니다. 다시 시도해주세요." + _finish_failure(study_material, message) + logger.warning( + "분석 결과 저장 시점 재검증 실패: study_material_id=%s, 사유=%s", + study_material.id, exc, + ) + raise AnalysisPipelineError(message) from exc except AIAnalysisError as exc: _finish_failure(study_material, str(exc)) logger.warning( diff --git a/exams/tests.py b/exams/tests.py index 1baa6c9..aa6c904 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -4,7 +4,7 @@ from unittest.mock import patch from django.db import connection -from django.test import TestCase, TransactionTestCase +from django.test import TestCase, TransactionTestCase, override_settings from django.urls import reverse from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile @@ -852,14 +852,16 @@ def setUp(self): exam=self.exam, title="테스트 자료", extracted_text="1장 개념 정리", ) + @override_settings(AI_MOCK_MODE=True) def test_call_ai_runs_without_open_transaction(self): observed_in_atomic_block = [] - original_call_ai = task_extractor._call_ai - def spy_call_ai(prompt): + # 이 테스트의 목적은 트랜잭션 유무 확인이지 실제 AI 응답 확인이 아니므로, + # 테스트 실행 환경에서 AI_MOCK_MODE가 어쩌다 False로 덮어써져도 실제 + # Gemini API를 호출하지 않도록 고정 응답을 직접 반환한다. observed_in_atomic_block.append(connection.in_atomic_block) - return original_call_ai(prompt) + return task_extractor._MOCK_RESPONSE with patch("exams.services.task_extractor._call_ai", side_effect=spy_call_ai): analyze_and_estimate(self.material) @@ -870,6 +872,7 @@ def spy_call_ai(prompt): "AI 네트워크 호출(_call_ai) 시점에 DB 트랜잭션이 열려있으면 안 된다.", ) + @override_settings(AI_MOCK_MODE=True) def test_studytask_creation_still_rolls_back_on_db_failure(self): """ 네트워크 호출을 트랜잭션 밖으로 뺐어도, DB 저장 단계 자체의 원자성은 @@ -880,4 +883,79 @@ def test_studytask_creation_still_rolls_back_on_db_failure(self): with self.assertRaises(AnalysisPipelineError): analyze_and_estimate(self.material) - self.assertEqual(StudyTask.objects.filter(study_material=self.material).count(), 0) \ No newline at end of file + self.assertEqual(StudyTask.objects.filter(study_material=self.material).count(), 0) + + @override_settings(AI_MOCK_MODE=True) + def test_stale_extracted_text_discards_result(self): + """ + AI 호출 시작 이후 저장 시점 사이에 extracted_text가 바뀌면(예: 다른 요청이 + PDF를 재추출), 그 사이 받은 AI 결과는 저장하지 않고 버려야 한다. + """ + def fake_fetch(exam, extracted_text): + # AI 응답을 기다리는 동안 다른 요청이 텍스트를 바꿔치기했다고 가정 + StudyMaterial.objects.filter(pk=self.material.pk).update( + extracted_text="다른 요청이 재추출한 새 텍스트" + ) + return task_extractor.fetch_extracted_tasks(exam, extracted_text) + + with patch( + "exams.services.analysis_orchestrator.fetch_extracted_tasks", side_effect=fake_fetch + ): + with self.assertRaises(AnalysisPipelineError): + analyze_and_estimate(self.material) + + self.assertEqual(StudyTask.objects.filter(study_material=self.material).count(), 0) + self.material.refresh_from_db() + self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) + # 텍스트 자체는 다른 요청이 바꾼 값 그대로 남아있어야 한다 (이 실행이 덮어쓰면 안 됨) + self.assertEqual(self.material.extracted_text, "다른 요청이 재추출한 새 텍스트") + + @override_settings(AI_MOCK_MODE=True) + def test_speed_factor_uses_latest_value_at_save_time(self): + """ + 예상시간 계산은 AI 호출 전에 로드해둔 오래된 exam 객체가 아니라, 저장 + 시점에 다시 조회한 최신 speed_factor를 사용해야 한다. + """ + def fake_fetch(exam, extracted_text): + # AI 응답을 기다리는 동안 progress 기록으로 speed_factor가 갱신됐다고 가정 + Exam.objects.filter(pk=self.exam.pk).update(speed_factor=2.0) + return task_extractor.fetch_extracted_tasks(exam, extracted_text) + + with patch( + "exams.services.analysis_orchestrator.fetch_extracted_tasks", side_effect=fake_fetch + ): + tasks = analyze_and_estimate(self.material) + + from planner.services.time_estimator import estimate_task_minutes + + first_task = tasks[0] + expected_min, expected_max = estimate_task_minutes( + task_type=first_task.task_type, + difficulty=first_task.difficulty, + speed_factor=2.0, # 저장 시점의 최신값 + ) + self.assertEqual(first_task.estimated_min_minutes, expected_min) + self.assertEqual(first_task.estimated_max_minutes, expected_max) + + @override_settings(AI_MOCK_MODE=True) + def test_existing_unconfirmed_task_preserved_when_save_rolls_back(self): + """ + save_extracted_tasks()는 기존 미확정 작업을 삭제하고 새로 만드는데, 그 + 직후(예상시간 계산) 실패로 트랜잭션이 롤백되면 기존 작업 삭제도 함께 + 되돌려져서 그대로 남아있어야 한다. + """ + old_task = StudyTask.objects.create( + exam=self.exam, study_material=self.material, title="기존 작업", + task_type="concept", importance="medium", depth="basic", + difficulty="normal", estimated_min_minutes=10, estimated_max_minutes=20, + order=1, + ) + + with patch("exams.services.analysis_orchestrator.estimate_task_minutes") as mock_estimate: + mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") + with self.assertRaises(AnalysisPipelineError): + analyze_and_estimate(self.material) + + self.assertTrue( + StudyTask.objects.filter(pk=old_task.pk, title="기존 작업").exists() + ) \ No newline at end of file From cf7465e1a6fbae538f35c050751a4fc9e78ce682 Mon Sep 17 00:00:00 2001 From: youkyeom Date: Wed, 5 Aug 2026 01:15:49 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20PDF=20=EC=9E=AC=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EA=B2=BD=EC=9F=81=20=EC=83=81=ED=83=9C=20=EB=B0=8F=20=EC=9E=AC?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EC=84=B1=EA=B3=B5=20=EC=8B=9C=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=83=81=ED=83=9C=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- exams/services/analysis_orchestrator.py | 26 ++++++- exams/tests.py | 99 +++++++++++++++++++++++++ exams/views.py | 43 +++++++++-- 3 files changed, 159 insertions(+), 9 deletions(-) diff --git a/exams/services/analysis_orchestrator.py b/exams/services/analysis_orchestrator.py index 92047f4..053341a 100644 --- a/exams/services/analysis_orchestrator.py +++ b/exams/services/analysis_orchestrator.py @@ -59,10 +59,18 @@ 추출해서 extracted_text가 바뀌는 경우). 이 경우를 대비해 _save_tasks_with_estimates()가 저장 직전에 StudyMaterial을 다시 조회해서 다음을 재검증한다. 1. analysis_status가 여전히 PROCESSING인지 - 2. extracted_text가 AI 호출 당시와 동일한지 (analyzed_text로 전달받아 비교) - 둘 중 하나라도 달라졌으면 StaleAnalysisRequestError를 던지고 아무것도 저장하지 않는다. + 2. status(텍스트 추출 상태)가 COMPLETED인지 - PDF 재추출이 진행 중(status가 + PROCESSING/FAILED/PENDING으로 바뀜)이면, 아직 extracted_text 자체는 안 + 바뀌었더라도 곧 바뀔 수 있는 불안정한 상태이므로 저장을 포기한다. (텍스트 + 비교만으로는 "재추출이 시작됐지만 아직 안 끝난" 시점을 못 걸러내기 때문에 + 별도로 확인이 필요했다.) + 3. extracted_text가 AI 호출 당시와 동일한지 (analyzed_text로 전달받아 비교) + 하나라도 어긋나면 StaleAnalysisRequestError를 던지고 아무것도 저장하지 않는다. - 같은 이유로 예상시간 계산에 쓰는 exam.speed_factor도, AI 호출 전에 로드해둔 오래된 객체가 아니라 저장 시점에 다시 조회한 최신 값을 사용한다. +- material_extract()(exams/views.py, BE2 담당) 쪽에도 analysis_status가 PROCESSING/ + COMPLETED인 자료의 재추출을 조건부 UPDATE로 원자적으로 막는 방어를 추가했다 + (Python에서 조회 후 검사하는 방식은 그 자체로 동시 요청 사이의 경쟁 상태가 남는다). """ from __future__ import annotations @@ -165,8 +173,10 @@ def _save_tasks_with_estimates( 저장 직전에 StudyMaterial을 다시 조회해서(select_for_update로 잠그면서), AI 호출 이후 상태가 바뀌지 않았는지 재검증한다: - analysis_status가 여전히 PROCESSING인지 + - status(텍스트 추출 상태)가 COMPLETED인지 (재추출이 진행 중이면 아직 + extracted_text 자체는 안 바뀌었어도 불안정한 상태로 간주) - extracted_text가 analyzed_text(AI 호출에 실제로 쓴 텍스트)와 같은지 - 둘 중 하나라도 어긋나면 StaleAnalysisRequestError를 던지고 아무것도 쓰지 않는다. + 하나라도 어긋나면 StaleAnalysisRequestError를 던지고 아무것도 쓰지 않는다. (select_for_update는 SQLite에서 실제 잠금이 걸리지는 않지만, 재조회 자체는 트랜잭션 안에서 최신 값을 가져오므로 최소한의 방어 역할은 한다.) @@ -186,6 +196,16 @@ def _save_tasks_with_estimates( f"(현재: {current.analysis_status}). study_material_id={study_material.pk}" ) + if current.status != MaterialStatus.COMPLETED: + # material_extract()가 PDF를 재추출 중이면 status가 PROCESSING/FAILED/PENDING으로 + # 바뀐다. 아직 extracted_text 자체는 안 바뀐 시점이라 아래 텍스트 비교만으로는 + # 못 걸러내므로, 추출 상태 자체도 별도로 확인한다 (재추출 완료 시점에 텍스트가 + # 바뀌기 전에 이 실행이 먼저 저장해버리는 걸 막기 위함). + raise StaleAnalysisRequestError( + f"저장 시점에 텍스트 추출 상태가 COMPLETED가 아닙니다 " + f"(현재: {current.status}). study_material_id={study_material.pk}" + ) + if current.extracted_text != analyzed_text: raise StaleAnalysisRequestError( f"저장 시점에 추출 텍스트가 AI 호출 당시와 달라 결과를 저장하지 않습니다. " diff --git a/exams/tests.py b/exams/tests.py index aa6c904..e8409e2 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -255,6 +255,74 @@ def test_extract_empty_text_marks_failed(self, mock_extract): self.assertEqual(material.status, MaterialStatus.FAILED) self.assertIn("스캔", material.error_message) + @patch('exams.views.extract_text_from_pdf') + def test_extract_success_resets_stale_analysis_state(self, mock_extract): + """ + 이전 텍스트 기준으로 FAILED였던 AI 분석 상태가, 재추출 성공(=새 텍스트로 + 교체) 후에는 PENDING/재시도횟수 0으로 초기화되어야 한다 (새 텍스트니까 + 최초 분석부터 다시 시작할 수 있어야 함). + """ + mock_extract.return_value = "새로 추출된 텍스트입니다." + material = self._make_pdf_material() + material.status = MaterialStatus.COMPLETED + material.extracted_text = "예전 텍스트" + material.analysis_status = MaterialStatus.FAILED + material.analysis_error_message = "예전 텍스트 기준 실패 사유" + material.analysis_retry_count = 1 + material.save(update_fields=[ + "status", "extracted_text", "analysis_status", + "analysis_error_message", "analysis_retry_count", + ]) + + self.client.post(reverse('exams:material_extract', args=[material.id])) + material.refresh_from_db() + + self.assertEqual(material.extracted_text, "새로 추출된 텍스트입니다.") + self.assertEqual(material.analysis_status, MaterialStatus.PENDING) + self.assertIsNone(material.analysis_error_message) + self.assertEqual(material.analysis_retry_count, 0) + + @patch('exams.views.extract_text_from_pdf') + def test_extract_blocked_when_analysis_processing(self, mock_extract): + """ + AI 분석이 진행 중인 자료는 재추출하면 안 된다 - 어느 텍스트 기준으로 + 분석 중인지 꼬일 수 있기 때문 (analysis_orchestrator 쪽 재검증과 짝을 이룸). + """ + material = self._make_pdf_material() + material.status = MaterialStatus.COMPLETED + material.extracted_text = "기존 추출 텍스트" + material.analysis_status = MaterialStatus.PROCESSING + material.save(update_fields=["status", "extracted_text", "analysis_status"]) + + response = self.client.post( + reverse('exams:material_extract', args=[material.id]), follow=True + ) + + material.refresh_from_db() + mock_extract.assert_not_called() + self.assertEqual(material.extracted_text, "기존 추출 텍스트") + messages_list = list(response.context['messages']) + self.assertTrue(any("AI 분석이 진행 중인" in str(m) for m in messages_list)) + + @patch('exams.views.extract_text_from_pdf') + def test_extract_blocked_when_analysis_completed(self, mock_extract): + """AI 분석이 이미 끝난 자료도 재추출하면 안 된다 (결과가 옛 텍스트 기준이 됨).""" + material = self._make_pdf_material() + material.status = MaterialStatus.COMPLETED + material.extracted_text = "기존 추출 텍스트" + material.analysis_status = MaterialStatus.COMPLETED + material.save(update_fields=["status", "extracted_text", "analysis_status"]) + + response = self.client.post( + reverse('exams:material_extract', args=[material.id]), follow=True + ) + + material.refresh_from_db() + mock_extract.assert_not_called() + self.assertEqual(material.extracted_text, "기존 추출 텍스트") + messages_list = list(response.context['messages']) + self.assertTrue(any("이미 AI 분석이 완료된" in str(m) for m in messages_list)) + class StudyTaskCreateTests(TestCase): """직접 추가한 학습 작업의 예상시간 계산""" @@ -375,6 +443,7 @@ def setUp(self): def _make_material(self, text="1장 개념 정리"): return StudyMaterial.objects.create( exam=self.exam, title="테스트 자료", extracted_text=text, + status=MaterialStatus.COMPLETED, ) # ---------- 최초 분석 ---------- @@ -850,6 +919,7 @@ def setUp(self): ) self.material = StudyMaterial.objects.create( exam=self.exam, title="테스트 자료", extracted_text="1장 개념 정리", + status=MaterialStatus.COMPLETED, ) @override_settings(AI_MOCK_MODE=True) @@ -910,6 +980,35 @@ def fake_fetch(exam, extracted_text): # 텍스트 자체는 다른 요청이 바꾼 값 그대로 남아있어야 한다 (이 실행이 덮어쓰면 안 됨) self.assertEqual(self.material.extracted_text, "다른 요청이 재추출한 새 텍스트") + @override_settings(AI_MOCK_MODE=True) + def test_stale_when_extraction_reprocessing_even_if_text_unchanged(self): + """ + PDF 재추출이 "시작"만 되고(status=PROCESSING) 아직 extracted_text 자체는 + 안 바뀐 시점에도, 저장을 포기해야 한다 (텍스트 비교만으로는 이 시점을 + 걸러낼 수 없어서 status도 별도로 확인해야 하는 케이스). + """ + original_text = self.material.extracted_text + + def fake_fetch(exam, extracted_text): + # 재추출이 막 시작됐다고 가정: status만 PROCESSING으로 바뀌고 + # extracted_text는 아직 원래 값 그대로인 상태 + StudyMaterial.objects.filter(pk=self.material.pk).update( + status=MaterialStatus.PROCESSING + ) + return task_extractor.fetch_extracted_tasks(exam, extracted_text) + + with patch( + "exams.services.analysis_orchestrator.fetch_extracted_tasks", side_effect=fake_fetch + ): + with self.assertRaises(AnalysisPipelineError): + analyze_and_estimate(self.material) + + self.assertEqual(StudyTask.objects.filter(study_material=self.material).count(), 0) + self.material.refresh_from_db() + self.assertEqual(self.material.analysis_status, MaterialStatus.FAILED) + # 텍스트 자체는 그대로였다는 것도 재확인 (이게 이 테스트의 핵심 포인트) + self.assertEqual(self.material.extracted_text, original_text) + @override_settings(AI_MOCK_MODE=True) def test_speed_factor_uses_latest_value_at_save_time(self): """ diff --git a/exams/views.py b/exams/views.py index 2877c33..1d8bb0f 100644 --- a/exams/views.py +++ b/exams/views.py @@ -288,13 +288,34 @@ def material_extract(request, material_id): messages.error(request, "첨부된 PDF 파일이 없습니다.") return redirect('exams:material_detail', material_id=material.id) - if material.status == MaterialStatus.PROCESSING: - messages.info(request, "이미 분석 중인 자료입니다.") + # 아래 세 조건 중 하나라도 걸리면 재추출을 막는다. + # - status(추출 상태)가 이미 PROCESSING (다른 요청이 추출 중) + # - analysis_status가 PROCESSING (AI 분석 진행 중 - 어느 텍스트 기준인지 꼬임) + # - analysis_status가 COMPLETED (이미 이 텍스트 기준으로 분석 결과가 있음) + # 조회 후 파이썬에서 검사하는 방식은 동시 요청 사이의 경쟁 상태가 남으므로, + # 조건부 UPDATE 하나로 "확인 + PROCESSING 전이"를 원자적으로 처리한다 + # (analysis_orchestrator._save_tasks_with_estimates()의 재검증과 짝을 이루는 방어). + updated_count = StudyMaterial.objects.filter(pk=material.pk).exclude( + status=MaterialStatus.PROCESSING + ).exclude( + analysis_status__in=[MaterialStatus.PROCESSING, MaterialStatus.COMPLETED] + ).update(status=MaterialStatus.PROCESSING, error_message=None) + + if not updated_count: + material.refresh_from_db(fields=['status', 'analysis_status']) + if material.status == MaterialStatus.PROCESSING: + messages.info(request, "이미 분석 중인 자료입니다.") + elif material.analysis_status == MaterialStatus.PROCESSING: + messages.error(request, "AI 분석이 진행 중인 자료는 다시 추출할 수 없습니다.") + else: + messages.error( + request, + "이미 AI 분석이 완료된 자료입니다. 다시 추출하려면 먼저 작업 검토 " + "화면에서 확인해주세요.", + ) return redirect('exams:material_detail', material_id=material.id) - material.status = MaterialStatus.PROCESSING - material.error_message = None - material.save(update_fields=['status', 'error_message']) + material.refresh_from_db(fields=['status', 'error_message']) try: extracted = extract_text_from_pdf(material.file) @@ -315,7 +336,17 @@ def material_extract(request, material_id): material.status = MaterialStatus.COMPLETED material.extracted_text = extracted material.error_message = None - material.save(update_fields=['status', 'extracted_text', 'error_message']) + # 재추출 성공은 곧 "새로운 분석 대상"이 됐다는 뜻이다. 이전 텍스트를 기준으로 + # 쌓였던 AI 분석 상태(특히 FAILED 사유, 재시도 횟수)는 새 텍스트와 무관하므로 + # 초기화해서, 사용자가 새 텍스트로 최초 분석부터 다시 시작할 수 있게 한다. + # (추출 실패 케이스에서는 extracted_text 자체가 안 바뀌므로 여기서 건드리지 않는다.) + material.analysis_status = MaterialStatus.PENDING + material.analysis_error_message = None + material.analysis_retry_count = 0 + material.save(update_fields=[ + 'status', 'extracted_text', 'error_message', + 'analysis_status', 'analysis_error_message', 'analysis_retry_count', + ]) messages.success(request, "PDF 텍스트 추출이 완료되었습니다.") return redirect('exams:material_detail', material_id=material.id) From fef32c18c4300ea4f0a5dff54718a05f9a33fb5f Mon Sep 17 00:00:00 2001 From: youkyeom Date: Wed, 5 Aug 2026 17:59:53 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20pdf=20=EC=B6=94=EC=B6=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20Ai=20=EB=B6=84=EC=84=9D=20=EC=8B=9C=EC=9E=91=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=84=B1=EA=B3=B5=20=EA=B2=BD=EC=9F=81=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B0=A9=EC=A7=80=20=EB=B0=8F=20material?= =?UTF-8?q?=5Fanalysis=5Fstatus=20=EC=B5=9C=EC=A2=85=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- exams/services/analysis_orchestrator.py | 68 +++++++++-- exams/tests.py | 153 +++++++++++++++++++++++- exams/views.py | 70 ++++++++++- 3 files changed, 273 insertions(+), 18 deletions(-) diff --git a/exams/services/analysis_orchestrator.py b/exams/services/analysis_orchestrator.py index 053341a..cd54406 100644 --- a/exams/services/analysis_orchestrator.py +++ b/exams/services/analysis_orchestrator.py @@ -71,6 +71,18 @@ - material_extract()(exams/views.py, BE2 담당) 쪽에도 analysis_status가 PROCESSING/ COMPLETED인 자료의 재추출을 조건부 UPDATE로 원자적으로 막는 방어를 추가했다 (Python에서 조회 후 검사하는 방식은 그 자체로 동시 요청 사이의 경쟁 상태가 남는다). + +PDF 추출 시작과 AI 분석 시작이 동시에 성공하는 경쟁 상태 (리뷰 반영): +- 위 저장 시점 재검증만으로는 못 막는 경우가 있었다: View가 material.status== + COMPLETED를 파이썬에서 확인한 직후, PDF 재추출 요청이 먼저 DB에서 status= + PROCESSING을 차지하고, 그 다음 이 파일의 _start_processing()이 analysis_status만 + 확인하고 PROCESSING 전이에 성공해버리는 경우다. 이러면 추출과 분석이 동시에 + 진행되다가, 나중에 _finish_failure()가 조건 없이 analysis_status=FAILED를 저장하면서 + 재추출 성공 후 초기화된 PENDING 상태를 덮어쓸 수 있었다. +- 해결: _start_processing()의 조건부 UPDATE에도 status=MaterialStatus.COMPLETED + 조건을 추가했다. material_extract()의 조건부 UPDATE(analysis_status가 PROCESSING/ + COMPLETED면 차단)와 서로 대칭을 이루게 되어, 두 요청 중 DB에 먼저 도달해 조건부 + UPDATE를 통과한 쪽만 성공하고 나머지는 원자적으로 실패한다. """ from __future__ import annotations @@ -308,21 +320,37 @@ def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> bool: """ analysis_status를 PROCESSING으로 원자적으로 전이시킨다. - - is_retry=False (최초 분석): 현재 analysis_status가 PENDING일 때만 전이 - - is_retry=True (재시도): 현재 analysis_status가 FAILED이고 - analysis_retry_count < MAX_RETRY_COUNT일 때만 전이, - 전이와 동시에 analysis_retry_count를 1 증가시킨다. + - is_retry=False (최초 분석): 현재 status(텍스트 추출 상태)가 COMPLETED이고 + analysis_status가 PENDING일 때만 전이 + - is_retry=True (재시도): 현재 status가 COMPLETED이고 analysis_status가 + FAILED이며 analysis_retry_count < MAX_RETRY_COUNT일 + 때만 전이, 전이와 동시에 analysis_retry_count를 + 1 증가시킨다. + + status=COMPLETED 조건을 넣은 이유 (리뷰 반영): 이 조건이 없으면 아래 경쟁 + 상태가 가능했다. + 1. View가 material.status==COMPLETED를 파이썬에서 확인 + 2. 그 직후 PDF 재추출 요청이 DB에서 status=PROCESSING을 먼저 차지 + 3. 이 함수가 analysis_status=PENDING만 확인하고 PROCESSING 전이에 성공 + 4. PDF 추출과 AI 분석이 동시에 실행되어, 나중에 _finish_failure()가 + 조건 없이 analysis_status=FAILED를 저장하면서 재추출 성공 후 + 초기화된 PENDING 상태를 덮어씀 + status=COMPLETED를 이 조건부 UPDATE 안에 같이 넣으면, material_extract() + 쪽의 조건부 UPDATE(analysis_status가 PROCESSING/COMPLETED면 재추출 차단)와 + 서로 대칭을 이뤄서, 두 요청 중 DB에 먼저 도달한 쪽만 성공하고 나머지는 + 원자적으로 실패하게 된다. DB 조건부 UPDATE 하나로 "확인 + 변경"을 원자적으로 처리하기 때문에, 동시에 같은 요청이 여러 번 들어와도 정확히 하나만 성공한다. Returns: True: 전이에 성공함 (study_material 인스턴스도 최신값으로 갱신됨) - False: 조건이 안 맞아 전이하지 못함 (이미 처리중/조건 불충족 등) + False: 조건이 안 맞아 전이하지 못함 (이미 처리중/조건 불충족/추출 미완료 등) """ if is_retry: updated_count = StudyMaterial.objects.filter( pk=study_material.pk, + status=MaterialStatus.COMPLETED, analysis_status=MaterialStatus.FAILED, analysis_retry_count__lt=MAX_RETRY_COUNT, ).update( @@ -333,6 +361,7 @@ def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> bool: else: updated_count = StudyMaterial.objects.filter( pk=study_material.pk, + status=MaterialStatus.COMPLETED, analysis_status=MaterialStatus.PENDING, ).update( analysis_status=MaterialStatus.PROCESSING, @@ -349,15 +378,21 @@ def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> bool: def analyze_and_estimate(study_material: StudyMaterial) -> list[StudyTask]: """ - E-AI-01 진입점. analysis_status가 PENDING일 때만 분석을 시작한다. + E-AI-01 진입점. 텍스트 추출(status)이 COMPLETED이고 analysis_status가 + PENDING일 때만 분석을 시작한다. Raises: - DuplicateAnalysisRequestError: PENDING이 아니어서(이미 진행/완료/실패) 시작 못 함 + DuplicateAnalysisRequestError: 시작 조건이 안 맞아 시작 못 함 (추출이 + 아직 진행 중이거나, analysis_status가 PENDING이 아님) AIAnalysisError 계열, AIResponseValidationError: 분석 자체가 실패함 """ started = _start_processing(study_material, is_retry=False) if not started: - study_material.refresh_from_db(fields=["analysis_status"]) + study_material.refresh_from_db(fields=["status", "analysis_status"]) + if study_material.status != MaterialStatus.COMPLETED: + raise DuplicateAnalysisRequestError( + "텍스트 추출이 진행 중이라 지금은 분석을 시작할 수 없습니다." + ) raise DuplicateAnalysisRequestError( f"분석을 시작할 수 없는 상태입니다 (현재 analysis_status: " f"{study_material.analysis_status})." @@ -367,17 +402,25 @@ def analyze_and_estimate(study_material: StudyMaterial) -> list[StudyTask]: def retry_analysis(study_material: StudyMaterial) -> list[StudyTask]: """ - E-AI-03 진입점. FAILED 상태이고 재시도 횟수가 남아있을 때만 재시도한다. + E-AI-03 진입점. 텍스트 추출(status)이 COMPLETED이고, analysis_status가 + FAILED이며 재시도 횟수가 남아있을 때만 재시도한다. Raises: - DuplicateAnalysisRequestError: 현재 PROCESSING이라 중복 요청인 경우 + DuplicateAnalysisRequestError: 추출이 진행 중이거나, analysis_status가 + PROCESSING이라 중복 요청인 경우 AnalysisNotSupportedError: COMPLETED 상태라 MVP 기준 재분석 미지원인 경우 RetryLimitExceededError: FAILED 상태이지만 재시도 횟수(2회)를 이미 다 쓴 경우 AIAnalysisError 계열, AIResponseValidationError: 재시도한 분석 자체가 실패함 """ started = _start_processing(study_material, is_retry=True) if not started: - study_material.refresh_from_db(fields=["analysis_status", "analysis_retry_count"]) + study_material.refresh_from_db(fields=["status", "analysis_status", "analysis_retry_count"]) + + if study_material.status != MaterialStatus.COMPLETED: + raise DuplicateAnalysisRequestError( + "텍스트 추출이 진행 중이라 지금은 재시도를 시작할 수 없습니다." + ) + status = study_material.analysis_status if status == MaterialStatus.PROCESSING: @@ -388,7 +431,8 @@ def retry_analysis(study_material: StudyMaterial) -> list[StudyTask]: "결과를 수정하려면 작업 검토 화면에서 직접 수정해주세요." ) if status == MaterialStatus.FAILED: - # FAILED인데도 전이 실패했다는 건 재시도 횟수를 이미 다 썼다는 뜻 + # FAILED이고 추출도 COMPLETED인데 전이 실패했다는 건 + # 재시도 횟수를 이미 다 썼다는 뜻 raise RetryLimitExceededError( "재시도 횟수(최대 2회)를 모두 사용했습니다. 학습 작업을 직접 추가해주세요." ) diff --git a/exams/tests.py b/exams/tests.py index e8409e2..9fd4918 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -629,6 +629,46 @@ def test_get_analysis_status_reports_retry_remaining(self): self.assertEqual(result["retry_count"], 1) self.assertEqual(result["retry_remaining"], MAX_RETRY_COUNT - 1) + # ---------- 추출 시작과 분석 시작의 경쟁 상태 (리뷰 반영) ---------- + + def test_initial_analysis_blocked_when_extraction_wins_race_after_status_check(self): + """ + View가 material.status==COMPLETED를 확인한 시점(이 material 객체는 메모리에 + COMPLETED로 남아있음) 직후, DB에서는 PDF 재추출이 먼저 status=PROCESSING을 + 차지했다고 가정한다. _start_processing()은 인메모리 값이 아니라 DB를 다시 + 조건부로 확인하므로, 이 경우 분석 시작 자체가 원자적으로 실패해야 한다. + """ + material = self._make_material() + self.assertEqual(material.status, MaterialStatus.COMPLETED) # View가 이미 확인한 상태 + + # PDF 재추출이 먼저 DB에서 PROCESSING을 차지했다고 가정 + # (material 인메모리 객체는 건드리지 않아 "직후" 시점을 그대로 재현) + StudyMaterial.objects.filter(pk=material.pk).update(status=MaterialStatus.PROCESSING) + + with self.assertRaises(DuplicateAnalysisRequestError): + analyze_and_estimate(material) + + material.refresh_from_db() + self.assertEqual(material.analysis_status, MaterialStatus.PENDING) # 시작조차 안 됨 + self.assertEqual(material.status, MaterialStatus.PROCESSING) # 추출 상태는 그대로 + + def test_retry_blocked_when_extraction_wins_race_after_status_check(self): + """재시도 경로에서도 동일한 경쟁 상태가 원자적으로 막혀야 한다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.FAILED + material.analysis_retry_count = 0 + material.save(update_fields=["analysis_status", "analysis_retry_count"]) + + StudyMaterial.objects.filter(pk=material.pk).update(status=MaterialStatus.PROCESSING) + + with self.assertRaises(DuplicateAnalysisRequestError): + retry_analysis(material) + + material.refresh_from_db() + self.assertEqual(material.analysis_status, MaterialStatus.FAILED) + self.assertEqual(material.analysis_retry_count, 0) # 증가 안 함 + self.assertEqual(material.status, MaterialStatus.PROCESSING) + class MaterialAnalysisViewTestCase(TestCase): """ @@ -739,8 +779,8 @@ def test_analysis_status_endpoint_returns_json(self): self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data["status"], MaterialStatus.FAILED) - self.assertEqual(data["error_message"], "네트워크 오류") + self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) + self.assertEqual(data["analysis_error_message"], "네트워크 오류") self.assertEqual(data["retry_count"], 1) self.assertEqual(data["retry_remaining"], 1) @@ -749,6 +789,115 @@ def test_other_user_cannot_view_analysis_status(self): response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) self.assertEqual(response.status_code, 404) + # ---------- stage/failed_stage 조합 (추출 상태 + 분석 상태 통합) ---------- + + def _get_stage(self): + self.client.force_login(self.owner) + response = self.client.get(reverse('exams:material_analysis_status', args=[self.material.id])) + return response.json() + + def test_stage_pending_when_nothing_started(self): + self.material.status = MaterialStatus.PENDING + self.material.analysis_status = MaterialStatus.PENDING + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "PENDING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_extracting_when_extraction_processing(self): + self.material.status = MaterialStatus.PROCESSING + self.material.save(update_fields=["status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "EXTRACTING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_failed_with_extraction_when_extraction_failed(self): + self.material.status = MaterialStatus.FAILED + self.material.save(update_fields=["status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "FAILED") + self.assertEqual(data["failed_stage"], "EXTRACTION") + + def test_stage_analyzing_when_analysis_processing(self): + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "ANALYZING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_failed_with_analysis_when_analysis_failed(self): + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.FAILED + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "FAILED") + self.assertEqual(data["failed_stage"], "ANALYSIS") + + def test_stage_completed_when_analysis_completed(self): + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.COMPLETED + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "COMPLETED") + self.assertIsNone(data["failed_stage"]) + + def test_stage_prioritizes_extraction_over_stale_analysis_failure(self): + """ + 재추출 중(status=PROCESSING)인데 이전 분석 실패 기록(analysis_status=FAILED)이 + 같이 남아있는 경우, 추출 상태를 우선해서 EXTRACTING으로 보여줘야 한다 + (이전 분석 실패가 잘못 노출되면 안 됨). + """ + self.material.status = MaterialStatus.PROCESSING + self.material.analysis_status = MaterialStatus.FAILED + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertEqual(data["stage"], "EXTRACTING") + self.assertIsNone(data["failed_stage"]) + + def test_stage_response_still_includes_existing_fields(self): + """stage/failed_stage 추가가 retry_count/retry_remaining, 그리고 + analysis_status/analysis_error_message 값을 안 건드리는지 확인.""" + self.material.analysis_status = MaterialStatus.FAILED + self.material.analysis_error_message = "테스트 실패 사유" + self.material.analysis_retry_count = 1 + self.material.save(update_fields=[ + "analysis_status", "analysis_error_message", "analysis_retry_count", + ]) + + data = self._get_stage() + + self.assertEqual(data["analysis_status"], MaterialStatus.FAILED) + self.assertEqual(data["analysis_error_message"], "테스트 실패 사유") + self.assertEqual(data["retry_count"], 1) + self.assertEqual(data["retry_remaining"], MAX_RETRY_COUNT - 1) + + def test_stage_response_includes_extraction_fields(self): + """extraction_status/extraction_error_message가 material.status/ + error_message 값을 그대로 반영하는지 확인 (BE2와 합의한 필드명).""" + self.material.status = MaterialStatus.FAILED + self.material.error_message = "PDF 추출 실패 사유" + self.material.save(update_fields=["status", "error_message"]) + + data = self._get_stage() + + self.assertEqual(data["extraction_status"], MaterialStatus.FAILED) + self.assertEqual(data["extraction_error_message"], "PDF 추출 실패 사유") + # ---------- 예기치 못한 파이프라인 예외 처리 (PR #33 리뷰 반영) ---------- @patch("exams.services.analysis_orchestrator.estimate_task_minutes") diff --git a/exams/views.py b/exams/views.py index 1d8bb0f..4258650 100644 --- a/exams/views.py +++ b/exams/views.py @@ -304,7 +304,7 @@ def material_extract(request, material_id): if not updated_count: material.refresh_from_db(fields=['status', 'analysis_status']) if material.status == MaterialStatus.PROCESSING: - messages.info(request, "이미 분석 중인 자료입니다.") + messages.info(request, "이미 PDF 텍스트를 추출 중인 자료입니다.") elif material.analysis_status == MaterialStatus.PROCESSING: messages.error(request, "AI 분석이 진행 중인 자료는 다시 추출할 수 없습니다.") else: @@ -440,13 +440,75 @@ def material_retry_analyze(request, material_id): @require_http_methods(["GET"]) def material_analysis_status(request, material_id): """ - E-AI-02: AI 분석 진행 상태 조회 (폴링용 JSON 엔드포인트). - "분석 중..." 화면에서 주기적으로 호출해 analysis_status 변화를 확인하는 용도. + E-AI-02: AI 분석 및 텍스트 추출 진행 상태 조회 (폴링용 JSON 엔드포인트). + "분석 중..." 화면에서 주기적으로 호출해 상태 변화를 확인하는 용도. + + 프론트가 material_analyze()를 순차 자동 호출하는 방식으로 가면서, 성공/실패 + 판단을 이 엔드포인트 하나로만 하기로 확정했다. 텍스트 추출 상태 + (material.status/error_message, BE2 담당 필드)와 AI 분석 상태 + (material.analysis_status/analysis_error_message, 이 파일 담당)를 하나의 + 스키마로 합쳐서 내려준다 (BE2와 필드명·구조 합의 완료). + + 최종 응답 스키마: + { + "stage": "PENDING | EXTRACTING | ANALYZING | COMPLETED | FAILED", + "extraction_status": "pending | processing | completed | failed", + "extraction_error_message": str | None, + "analysis_status": "pending | processing | completed | failed", + "analysis_error_message": str | None, + "failed_stage": "EXTRACTION | ANALYSIS" | None, + "retry_count": int, + "retry_remaining": int, + } + extraction_status/analysis_status는 StudyMaterial 모델 필드 값을 그대로 내려서 + 소문자다 (MaterialStatus TextChoices 자체가 소문자). stage/failed_stage는 이 + 엔드포인트가 새로 만드는 값이라 대문자로 통일했다. + + stage 우선순위가 "추출 상태 먼저, 분석 상태 나중"인 이유: material_extract()의 + 원자적 방어(analysis_status가 PROCESSING/COMPLETED면 재추출 자체가 막힘) 덕분에, + material.status가 PROCESSING/FAILED로 남아있다는 건 "지금 추출(재추출 포함) + 작업이 진행/실패한 것"이 확정적으로 최신 상황이라는 뜻이다. 그래서 이 경우엔 + analysis_status에 남아있는 이전 분석 기록(예: 재추출 전의 예전 실패 사유)보다 + 추출 상태를 우선해서 보여준다 - 순서를 반대로 하면(분석 실패를 먼저 체크하면), + 재추출이 한창 진행 중인데도 stage가 잘못 FAILED로 나오는 문제가 생긴다. + + failed_stage: stage가 "FAILED"일 때, 추출 단계에서 실패한 건지("EXTRACTION") + 분석 단계에서 실패한 건지("ANALYSIS") 구분해서 알려준다. 둘 다 아니면 None. """ material = get_object_or_404( StudyMaterial, id=material_id, exam__exam_period__user=request.user ) - return JsonResponse(get_analysis_status(material)) + analysis_data = get_analysis_status(material) # 기존 서비스 함수, 시그니처 안 바뀜 + + if material.status == MaterialStatus.FAILED: + stage = "FAILED" + failed_stage = "EXTRACTION" + elif material.status == MaterialStatus.PROCESSING: + stage = "EXTRACTING" + failed_stage = None + elif analysis_data["status"] == MaterialStatus.PROCESSING: + stage = "ANALYZING" + failed_stage = None + elif analysis_data["status"] == MaterialStatus.FAILED: + stage = "FAILED" + failed_stage = "ANALYSIS" + elif analysis_data["status"] == MaterialStatus.COMPLETED: + stage = "COMPLETED" + failed_stage = None + else: + stage = "PENDING" + failed_stage = None + + return JsonResponse({ + "stage": stage, + "extraction_status": material.status, + "extraction_error_message": material.error_message, + "analysis_status": analysis_data["status"], + "analysis_error_message": analysis_data["error_message"], + "failed_stage": failed_stage, + "retry_count": analysis_data["retry_count"], + "retry_remaining": analysis_data["retry_remaining"], + }) # ===================================================================== From 117773072343730d3863024786a1d32738e57372 Mon Sep 17 00:00:00 2001 From: youkyeom Date: Wed, 5 Aug 2026 18:19:53 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=9E=AC=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=8F=99=EC=9D=BC=20=EC=8B=9C=20?= =?UTF-8?q?AI=20=EB=B6=84=EC=84=9D=20=EC=83=81=ED=83=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- exams/tests.py | 28 ++++++++++++++++++++++++++++ exams/views.py | 21 ++++++++++++++------- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/exams/tests.py b/exams/tests.py index 9fd4918..d9e0216 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -282,6 +282,34 @@ def test_extract_success_resets_stale_analysis_state(self, mock_extract): self.assertIsNone(material.analysis_error_message) self.assertEqual(material.analysis_retry_count, 0) + @patch('exams.views.extract_text_from_pdf') + def test_extract_success_keeps_analysis_state_when_text_unchanged(self, mock_extract): + """ + 리뷰 반영: 재추출 결과가 기존 텍스트와 완전히 같다면(예: 같은 PDF를 실수로 + 다시 업로드), AI 분석 상태를 초기화하면 안 된다. 무조건 초기화하면 재시도 + 횟수를 이미 다 쓴 자료도 같은 PDF를 다시 추출하는 것만으로 + analysis_retry_count가 0으로 리셋되어 재시도 제한을 우회할 수 있다. + """ + mock_extract.return_value = "변하지 않는 텍스트입니다." + material = self._make_pdf_material() + material.status = MaterialStatus.COMPLETED + material.extracted_text = "변하지 않는 텍스트입니다." + material.analysis_status = MaterialStatus.FAILED + material.analysis_error_message = "예전 실패 사유" + material.analysis_retry_count = MAX_RETRY_COUNT + material.save(update_fields=[ + "status", "extracted_text", "analysis_status", + "analysis_error_message", "analysis_retry_count", + ]) + + self.client.post(reverse('exams:material_extract', args=[material.id])) + material.refresh_from_db() + + self.assertEqual(material.extracted_text, "변하지 않는 텍스트입니다.") + self.assertEqual(material.analysis_status, MaterialStatus.FAILED) + self.assertEqual(material.analysis_error_message, "예전 실패 사유") + self.assertEqual(material.analysis_retry_count, MAX_RETRY_COUNT) + @patch('exams.views.extract_text_from_pdf') def test_extract_blocked_when_analysis_processing(self, mock_extract): """ diff --git a/exams/views.py b/exams/views.py index 4258650..e297f43 100644 --- a/exams/views.py +++ b/exams/views.py @@ -279,6 +279,10 @@ def material_extract(request, material_id): material = get_object_or_404( StudyMaterial, id=material_id, exam__exam_period__user=request.user ) + # 재추출 성공 후 "텍스트가 실제로 바뀌었는지" 판단하는 기준값. 아래에서 + # material.status/analysis_status 등을 refresh_from_db()로 갱신해도 + # extracted_text 필드는 그 refresh 대상에 포함하지 않으므로 이 시점 값 그대로 유지된다. + previous_extracted_text = material.extracted_text if material.material_type != MaterialType.PDF: messages.error(request, "PDF 자료만 텍스트 추출이 가능합니다.") @@ -336,13 +340,16 @@ def material_extract(request, material_id): material.status = MaterialStatus.COMPLETED material.extracted_text = extracted material.error_message = None - # 재추출 성공은 곧 "새로운 분석 대상"이 됐다는 뜻이다. 이전 텍스트를 기준으로 - # 쌓였던 AI 분석 상태(특히 FAILED 사유, 재시도 횟수)는 새 텍스트와 무관하므로 - # 초기화해서, 사용자가 새 텍스트로 최초 분석부터 다시 시작할 수 있게 한다. - # (추출 실패 케이스에서는 extracted_text 자체가 안 바뀌므로 여기서 건드리지 않는다.) - material.analysis_status = MaterialStatus.PENDING - material.analysis_error_message = None - material.analysis_retry_count = 0 + # 재추출 성공은 텍스트가 바뀌었을 수도, 완전히 같을 수도 있다 (예: 사용자가 + # 같은 PDF를 실수로 다시 업로드). 텍스트가 실제로 바뀐 경우에만 "새로운 분석 + # 대상"으로 보고 AI 분석 상태(특히 FAILED 사유, 재시도 횟수)를 초기화한다. + # 리뷰 반영: 텍스트가 동일한데도 무조건 초기화하면, 재시도 2회를 이미 다 쓴 + # 자료도 같은 PDF를 다시 추출하는 것만으로 retry_count가 0으로 리셋되어 + # 재시도 횟수 제한을 우회할 수 있었다. + if extracted != previous_extracted_text: + material.analysis_status = MaterialStatus.PENDING + material.analysis_error_message = None + material.analysis_retry_count = 0 material.save(update_fields=[ 'status', 'extracted_text', 'error_message', 'analysis_status', 'analysis_error_message', 'analysis_retry_count',