diff --git a/WHartTest_Django/knowledge/services.py b/WHartTest_Django/knowledge/services.py index 8e64595..ea98dc3 100644 --- a/WHartTest_Django/knowledge/services.py +++ b/WHartTest_Django/knowledge/services.py @@ -1947,7 +1947,9 @@ def _rewrite_query(self, query: str) -> Optional[str]: model=config.name, api_key=config.api_key, base_url=config.api_url, - temperature=0.3, + temperature=1 + if (config.name or "").lower().startswith("kimi-k3") + else 0.3, max_tokens=100, timeout=15, ) diff --git a/WHartTest_Django/langgraph_integration/views.py b/WHartTest_Django/langgraph_integration/views.py index 8a856c0..5279b22 100644 --- a/WHartTest_Django/langgraph_integration/views.py +++ b/WHartTest_Django/langgraph_integration/views.py @@ -192,6 +192,8 @@ def create_llm_instance(active_config, temperature=0.7): - max_retries: 最大重试次数,处理临时网络问题 """ model_identifier = active_config.name or "gpt-3.5-turbo" + if model_identifier.lower().startswith("kimi-k3"): + temperature = 1 provider = (getattr(active_config, "provider", None) or "openai_compatible").strip() # 从配置获取超时设置,默认120秒(LLM响应可能较慢) diff --git a/WHartTest_Django/requirements/services.py b/WHartTest_Django/requirements/services.py index 7b33624..1d8c7f9 100644 --- a/WHartTest_Django/requirements/services.py +++ b/WHartTest_Django/requirements/services.py @@ -1,6 +1,7 @@ -import logging -import json -import re +import logging +import json +import math +import re from string import Template from typing import List, Dict, Any, Optional from django.conf import settings @@ -11,23 +12,44 @@ from .models import RequirementDocument, RequirementModule, DocumentImage from prompts.models import UserPrompt -logger = logging.getLogger(__name__) - - -def create_llm_instance(active_config, temperature=0.1): +logger = logging.getLogger(__name__) + + +def normalize_score(value, default=70): + """Convert an LLM-provided score to a database-safe integer in 0..100.""" + try: + if value is None or isinstance(value, bool): + raise ValueError + score = float(value) + if not math.isfinite(score): + raise ValueError + except (TypeError, ValueError): + score = float(default) + + return max(0, min(100, int(round(score)))) + + +def create_llm_instance(active_config, temperature=0.1): """ 根据配置创建LLM实例 统一使用OpenAI兼容格式,支持所有兼容的服务商 - """ - model_identifier = active_config.name or "gpt-3.5-turbo" - - llm_kwargs = { + """ + model_identifier = active_config.name or "gpt-3.5-turbo" + if model_identifier.lower().startswith("kimi-k3"): + temperature = 1 + + configured_retries = getattr(active_config, "max_retries", None) + max_retries = 3 if configured_retries is None else max(0, int(configured_retries)) + configured_timeout = getattr(active_config, "request_timeout", None) + request_timeout = 300 if configured_timeout is None else max(1, int(configured_timeout)) + + llm_kwargs = { "model": model_identifier, "temperature": temperature, "api_key": active_config.api_key, "base_url": active_config.api_url, - "max_retries": 3, - "timeout": 120, + "max_retries": max_retries, + "timeout": request_timeout, } llm = ChatOpenAI(**llm_kwargs) logger.info( @@ -86,12 +108,10 @@ def safe_llm_invoke(llm, messages, max_retries=3, retry_delay=2): time.sleep(retry_delay * (attempt + 1)) continue raise - except Exception as e: - last_error = e - logger.warning(f"LLM 调用失败: {e},尝试重试 ({attempt + 1}/{max_retries})") - if attempt < max_retries - 1: - time.sleep(retry_delay * (attempt + 1)) - continue + except Exception: + # ChatOpenAI already applies the configured network retries. Retrying + # again here multiplies a 300-second timeout into hour-long stalls. + raise # 所有重试都失败 raise last_error or Exception("LLM 调用失败,所有重试都未成功") @@ -3119,8 +3139,11 @@ def analyze_document_comprehensive( for future in as_completed(future_to_analysis): analysis_name, display_name = future_to_analysis[future] try: - result = future.result() - results[analysis_name] = result + result = future.result() + result["overall_score"] = normalize_score( + result.get("overall_score"), 70 + ) + results[analysis_name] = result # 收集图片警告(如果有) if result.get("image_warning") and not image_warning: image_warning = result.get("image_warning") @@ -3221,8 +3244,9 @@ def _generate_comprehensive_report_v2(self, analyses: dict) -> dict: clarity, logic, ]: - score = analysis.get("overall_score", 70) - scores.append(score) + score = normalize_score(analysis.get("overall_score"), 70) + analysis["overall_score"] = score + scores.append(score) overall_score = int(sum(scores) / len(scores)) if scores else 70 @@ -3878,10 +3902,11 @@ def progress_callback( except Exception as e: logger.error(f"评审失败: {e}") - # 更新失败状态 - if "review_report" in locals(): - review_report.status = "failed" - review_report.save() + # 更新失败状态 + if "review_report" in locals(): + # Avoid saving other dirty fields (for example an LLM-provided + # None score) while recording the failure state. + ReviewReport.objects.filter(pk=review_report.pk).update(status="failed") document.status = "failed" document.save() @@ -3893,7 +3918,9 @@ def _update_review_report( ): """更新评审报告基本信息和专项分析详情""" review_report.overall_rating = analysis_result.get("overall_rating", "average") - review_report.completion_score = analysis_result.get("overall_score", 0) + review_report.completion_score = normalize_score( + analysis_result.get("overall_score"), 70 + ) review_report.total_issues = analysis_result.get("total_issues", 0) review_report.high_priority_issues = analysis_result.get( "high_priority_issues", 0 @@ -3910,8 +3937,22 @@ def _update_review_report( ) # 保存专项分析详情(包含issues, strengths, recommendations等完整数据) - specialized_analyses = analysis_result.get("specialized_analyses", {}) - review_report.specialized_analyses = specialized_analyses + specialized_analyses = analysis_result.get("specialized_analyses", {}) + analysis_keys = ( + "completeness_analysis", + "consistency_analysis", + "clarity_analysis", + "testability_analysis", + "feasibility_analysis", + "logic_analysis", + ) + for key in analysis_keys: + detail = specialized_analyses.get(key) + if not isinstance(detail, dict): + detail = {} + specialized_analyses[key] = detail + detail["overall_score"] = normalize_score(detail.get("overall_score"), 70) + review_report.specialized_analyses = specialized_analyses # 同时保存各专项分析的分数到独立字段 review_report.completeness_score = specialized_analyses.get( diff --git a/WHartTest_Django/requirements/tasks.py b/WHartTest_Django/requirements/tasks.py index 75530a9..3ef06a1 100644 --- a/WHartTest_Django/requirements/tasks.py +++ b/WHartTest_Django/requirements/tasks.py @@ -1,15 +1,21 @@ """ 需求评审异步任务 """ -import logging -from celery import shared_task -from django.utils import timezone +import logging +from celery import shared_task +from django.conf import settings +from django.utils import timezone logger = logging.getLogger(__name__) -@shared_task(bind=True, name='requirements.execute_requirement_review') -def execute_requirement_review(self, document_id, analysis_options=None, review_type='comprehensive', user_id=None): +@shared_task( + bind=True, + name='requirements.execute_requirement_review', + time_limit=settings.REQUIREMENT_REVIEW_TASK_TIME_LIMIT, + soft_time_limit=settings.REQUIREMENT_REVIEW_TASK_SOFT_TIME_LIMIT, +) +def execute_requirement_review(self, document_id, analysis_options=None, review_type='comprehensive', user_id=None): """ 异步执行需求评审任务 @@ -85,4 +91,4 @@ def execute_requirement_review(self, document_id, analysis_options=None, review_ return { 'status': 'error', 'message': str(e) - } \ No newline at end of file + } diff --git a/WHartTest_Django/requirements/test_review_regressions.py b/WHartTest_Django/requirements/test_review_regressions.py new file mode 100644 index 0000000..8f8c109 --- /dev/null +++ b/WHartTest_Django/requirements/test_review_regressions.py @@ -0,0 +1,48 @@ +from django.test import SimpleTestCase + +from requirements.services import ( + RequirementReviewService, + normalize_score, +) + + +class NormalizeScoreTests(SimpleTestCase): + def test_normalizes_missing_strings_and_out_of_range_values(self): + self.assertEqual(normalize_score(None), 70) + self.assertEqual(normalize_score("62"), 62) + self.assertEqual(normalize_score(-5), 0) + self.assertEqual(normalize_score(105), 100) + self.assertEqual(normalize_score(True), 70) + + def test_report_fields_never_receive_null_scores(self): + class Report: + def save(self): + self.saved = True + + report = Report() + result = { + "overall_score": "67", + "overall_rating": "needs_improvement", + "recommendations": [], + "specialized_analyses": { + "completeness_analysis": {"overall_score": 70}, + "consistency_analysis": {"overall_score": "62"}, + "clarity_analysis": {"overall_score": 78.4}, + "testability_analysis": {"overall_score": 52}, + "feasibility_analysis": {"overall_score": None}, + "logic_analysis": {"overall_score": 70}, + }, + } + + service = RequirementReviewService.__new__(RequirementReviewService) + service._update_review_report(report, result) + + self.assertTrue(report.saved) + self.assertEqual(report.completion_score, 67) + self.assertEqual(report.consistency_score, 62) + self.assertEqual(report.clarity_score, 78) + self.assertEqual(report.feasibility_score, 70) + self.assertEqual( + report.specialized_analyses["feasibility_analysis"]["overall_score"], + 70, + ) diff --git a/WHartTest_Django/wharttest_django/settings.py b/WHartTest_Django/wharttest_django/settings.py index d87e219..a743ea3 100644 --- a/WHartTest_Django/wharttest_django/settings.py +++ b/WHartTest_Django/wharttest_django/settings.py @@ -704,8 +704,20 @@ def setup_huggingface_env(): # Celery任务配置 CELERY_TASK_TRACK_STARTED = True # 追踪任务开始状态。 -CELERY_TASK_TIME_LIMIT = 30 * 60 # 任务硬超时(秒,30 分钟)。 -CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 任务软超时(秒,25 分钟)。 +CELERY_TASK_TIME_LIMIT = 30 * 60 # 任务硬超时(秒,30 分钟)。 +CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 任务软超时(秒,25 分钟)。 + +# Requirement reviews make several long-running LLM calls. Keep their larger +# limit task-specific so unrelated background work still fails promptly. +REQUIREMENT_REVIEW_TASK_TIME_LIMIT = int( + os.environ.get("REQUIREMENT_REVIEW_TASK_TIME_LIMIT", str(3 * 60 * 60)) +) +REQUIREMENT_REVIEW_TASK_SOFT_TIME_LIMIT = int( + os.environ.get( + "REQUIREMENT_REVIEW_TASK_SOFT_TIME_LIMIT", + str(REQUIREMENT_REVIEW_TASK_TIME_LIMIT - 5 * 60), + ) +) # Celery Worker配置 CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Worker 预取任务数量。 diff --git a/WHartTest_Vue/src/features/requirements/views/DocumentDetailView.vue b/WHartTest_Vue/src/features/requirements/views/DocumentDetailView.vue index 51178f5..3931139 100644 --- a/WHartTest_Vue/src/features/requirements/views/DocumentDetailView.vue +++ b/WHartTest_Vue/src/features/requirements/views/DocumentDetailView.vue @@ -826,8 +826,24 @@ const reviewProgress = ref<{ completed_steps: string[]; } | null>(null); -// 轮询控制标志 -let isPollingActive = false; +// 轮询控制标志 +let isPollingActive = false; +let reviewPollTimer: ReturnType | null = null; + +const stopReviewPolling = () => { + isPollingActive = false; + if (reviewPollTimer !== null) { + clearTimeout(reviewPollTimer); + reviewPollTimer = null; + } +}; + +const scheduleReviewPoll = (callback: () => void, delay: number) => { + if (reviewPollTimer !== null) { + clearTimeout(reviewPollTimer); + } + reviewPollTimer = setTimeout(callback, delay); +}; // 计算属性 const sortedModules = computed(() => { @@ -946,12 +962,19 @@ const getCurrentStep = (status: DocumentStatus) => { return stepMap[status] || 0; }; // 加载文档详情 -const loadDocument = async () => { - const documentId = route.params.id as string; - if (!documentId) { - Message.error(pageText.value.missingDocumentId); - return; - } +const loadDocument = async ( + documentIdOverride?: string, + autoStartPolling = true +) => { + const routeDocumentId = route.params.id; + const documentId = documentIdOverride + || (typeof routeDocumentId === 'string' ? routeDocumentId : undefined); + if (!documentId) { + if (autoStartPolling) { + Message.error(pageText.value.missingDocumentId); + } + return; + } loading.value = true; try { @@ -961,9 +984,9 @@ const loadDocument = async () => { document.value = response.data; // 如果文档正在评审中且没有正在进行的轮询,自动开始轮询进度 - if (document.value?.status === 'reviewing' && !isPollingActive) { - reviewLoading.value = true; - pollDocumentStatus(); + if (autoStartPolling && document.value?.status === 'reviewing' && !isPollingActive) { + reviewLoading.value = true; + pollDocumentStatus(); } } else { Message.error(response.message || pageText.value.loadDocumentFailed); @@ -977,9 +1000,10 @@ const loadDocument = async () => { }; // 返回列表 -const goBack = () => { - router.push('/requirements'); -}; +const goBack = () => { + stopReviewPolling(); + router.push('/requirements'); +}; const goToDocxEditor = async () => { if (!document.value?.id || docxEditorLoading.value) return; @@ -1145,21 +1169,27 @@ const confirmReview = async () => { }; // 轮询文档状态 -const pollDocumentStatus = async () => { - const maxAttempts = 60; // 最多轮询60次(5分钟) - let attempts = 0; - isPollingActive = true; - - const poll = async () => { +const pollDocumentStatus = async () => { + if (isPollingActive) return; + + const documentId = document.value?.id + || (typeof route.params.id === 'string' ? route.params.id : undefined); + if (!documentId) return; + + const pollingDeadline = Date.now() + 3 * 60 * 60 * 1000; + const maxConsecutiveErrors = 10; + let consecutiveErrors = 0; + isPollingActive = true; + + const poll = async () => { // 如果组件已卸载或轮询被停止,则退出 if (!isPollingActive) { return; } - attempts++; - - try { - await loadDocument(); + try { + await loadDocument(documentId, false); + consecutiveErrors = 0; // 更新进度信息(从最新的评审报告获取) if (document.value?.status === 'reviewing' && document.value?.latest_review) { @@ -1173,48 +1203,48 @@ const pollDocumentStatus = async () => { if (document.value?.status === 'review_completed') { // 评审完成 - isPollingActive = false; - reviewLoading.value = false; + stopReviewPolling(); + reviewLoading.value = false; reviewProgress.value = null; Message.success(pageText.value.reviewCompletedMessage); return; } else if (document.value?.status === 'failed') { // 评审失败 - isPollingActive = false; - reviewLoading.value = false; + stopReviewPolling(); + reviewLoading.value = false; reviewProgress.value = null; Message.error(pageText.value.reviewFailedMessage); return; - } else if (attempts >= maxAttempts) { - // 超时 - isPollingActive = false; - reviewLoading.value = false; + } else if (Date.now() >= pollingDeadline) { + // 超时 + stopReviewPolling(); + reviewLoading.value = false; reviewProgress.value = null; Message.warning(pageText.value.reviewTimeout); return; } - // 继续轮询,每3秒一次(更频繁地更新进度) - if (isPollingActive) { - setTimeout(poll, 3000); - } - } catch (error) { - console.error('轮询文档状态失败:', error); - attempts++; - if (attempts < maxAttempts && isPollingActive) { - setTimeout(poll, 3000); - } else { - isPollingActive = false; - reviewLoading.value = false; + // 继续轮询,每3秒一次(更频繁地更新进度) + if (isPollingActive) { + scheduleReviewPoll(poll, 3000); + } + } catch (error) { + console.error('轮询文档状态失败:', error); + consecutiveErrors++; + if (consecutiveErrors < maxConsecutiveErrors && isPollingActive) { + scheduleReviewPoll(poll, 3000); + } else { + stopReviewPolling(); + reviewLoading.value = false; reviewProgress.value = null; Message.error(pageText.value.fetchReviewStatusFailed); } } - }; - - // 首次轮询延迟2秒 - setTimeout(poll, 2000); -}; + }; + + // 首次轮询延迟2秒 + scheduleReviewPoll(poll, 2000); +}; // 模块展开/收起 const toggleModuleExpand = (moduleId: string) => { @@ -1606,9 +1636,9 @@ watch( ); // 组件卸载时停止轮询 -onBeforeUnmount(() => { - isPollingActive = false; -}); +onBeforeUnmount(() => { + stopReviewPolling(); +});