From fa77c68d774a089f5fd975f9161ccf9e0e20af23 Mon Sep 17 00:00:00 2001 From: bruce Date: Sat, 6 Jun 2026 17:56:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20=E5=A2=9E=E5=8A=A0=20LLM=20?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E4=B8=8E=E8=AF=8A=E6=96=AD=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/settings.py | 23 +++ .../services/attachment_reader.py | 35 ++++ .../file_summary/services/export_excel.py | 12 +- review_agent/file_summary/services/report.py | 9 + .../file_summary/skills/archive_extract.py | 20 ++ .../file_summary/skills/attachment_reader.py | 23 ++- .../skills/document_page_count.py | 44 ++++ .../file_summary/skills/file_inventory.py | 16 ++ .../file_summary/skills/product_detect.py | 10 + review_agent/file_summary/skills/registry.py | 24 ++- .../file_summary/skills/summary_report.py | 14 ++ review_agent/file_summary/storage.py | 26 ++- review_agent/file_summary/views.py | 41 ++++ review_agent/file_summary/workflow.py | 45 +++++ review_agent/file_summary/workflow_trigger.py | 33 ++- review_agent/llm.py | 41 ++++ review_agent/services.py | 78 +++++++- review_agent/skill_router.py | 189 ++++++++++++++++++ tests/test_file_summary_skills.py | 19 ++ tests/test_file_summary_trigger.py | 43 +++- tests/test_file_summary_workflow.py | 104 ++++++++++ 21 files changed, 832 insertions(+), 17 deletions(-) create mode 100644 review_agent/skill_router.py diff --git a/config/settings.py b/config/settings.py index a4f9fae..a2260fa 100644 --- a/config/settings.py +++ b/config/settings.py @@ -104,3 +104,26 @@ LOGOUT_REDIRECT_URL = "login" LLM_API_KEY = os.environ.get("LLM_API_KEY", "") LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.siliconflow.cn/v1") LLM_MODEL = os.environ.get("LLM_MODEL", "") + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "formatters": { + "verbose": { + "format": "%(asctime)s %(levelname)s %(name)s %(message)s", + }, + }, + "loggers": { + "review_agent": { + "handlers": ["console"], + "level": os.environ.get("REVIEW_AGENT_LOG_LEVEL", "INFO"), + "propagate": True, + }, + }, +} diff --git a/review_agent/file_summary/services/attachment_reader.py b/review_agent/file_summary/services/attachment_reader.py index 4f629aa..8f7cbb5 100644 --- a/review_agent/file_summary/services/attachment_reader.py +++ b/review_agent/file_summary/services/attachment_reader.py @@ -1,6 +1,7 @@ from __future__ import annotations import csv +import logging from dataclasses import asdict, dataclass, field from pathlib import Path @@ -15,6 +16,9 @@ MAX_PREVIEW_CHARS = 3000 MAX_ROWS_PER_SHEET = 20 +logger = logging.getLogger("review_agent.file_summary.attachment_reader") + + @dataclass(frozen=True) class AttachmentReadResult: status: str @@ -32,10 +36,29 @@ class AttachmentReadResult: def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult: file_path = _attachment_absolute_path(attachment) file_type = Path(attachment.original_name).suffix.lower().lstrip(".") + logger.info( + "Attachment read started", + extra={ + "attachment_id": attachment.pk, + "conversation_id": attachment.conversation_id, + "original_name": attachment.original_name, + "file_type": file_type, + "storage_path": attachment.storage_path, + "resolved_path": str(file_path), + }, + ) if not file_path.exists(): + logger.warning( + "Attachment read missing file", + extra={"attachment_id": attachment.pk, "resolved_path": str(file_path)}, + ) return _failed(attachment, file_type, "附件文件不存在。") if file_type not in SUPPORTED_EXTENSIONS: + logger.warning( + "Attachment read unsupported type", + extra={"attachment_id": attachment.pk, "file_type": file_type}, + ) return _failed(attachment, file_type, f"暂不支持解析 .{file_type or 'unknown'} 文件。", "unsupported") try: @@ -52,9 +75,21 @@ def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult: else: sections = _read_text(file_path) except Exception as exc: + logger.exception( + "Attachment read failed", + extra={"attachment_id": attachment.pk, "file_type": file_type, "error": str(exc)}, + ) return _failed(attachment, file_type, str(exc)) preview = _build_preview(sections) + logger.info( + "Attachment read finished", + extra={ + "attachment_id": attachment.pk, + "section_count": len(sections), + "preview_length": len(preview), + }, + ) return AttachmentReadResult( status="success", filename=attachment.original_name, diff --git a/review_agent/file_summary/services/export_excel.py b/review_agent/file_summary/services/export_excel.py index b09a6a7..b5b370d 100644 --- a/review_agent/file_summary/services/export_excel.py +++ b/review_agent/file_summary/services/export_excel.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from django.conf import settings @@ -8,6 +9,9 @@ from openpyxl import Workbook from review_agent.models import ExportedSummaryFile, FileSummaryBatch +logger = logging.getLogger("review_agent.file_summary.export_excel") + + def _exports_dir(batch: FileSummaryBatch) -> Path: root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "file_summary" / batch.batch_no export_dir = root / "exports" @@ -16,6 +20,7 @@ def _exports_dir(batch: FileSummaryBatch) -> Path: def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile: + logger.info("Excel export generation started", extra={"batch_id": batch.pk}) workbook = Workbook() summary = workbook.active summary.title = "汇总信息" @@ -47,9 +52,14 @@ def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile: path = _exports_dir(batch) / f"{batch.batch_no}-summary.xlsx" workbook.save(path) - return ExportedSummaryFile.objects.create( + exported = ExportedSummaryFile.objects.create( batch=batch, export_type=ExportedSummaryFile.ExportType.EXCEL, file_name=path.name, storage_path=str(path), ) + logger.info( + "Excel export generation finished", + extra={"batch_id": batch.pk, "export_id": exported.pk, "path": str(path)}, + ) + return exported diff --git a/review_agent/file_summary/services/report.py b/review_agent/file_summary/services/report.py index 0da3f4f..a1f9fc9 100644 --- a/review_agent/file_summary/services/report.py +++ b/review_agent/file_summary/services/report.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from django.conf import settings @@ -7,6 +8,9 @@ from django.conf import settings from review_agent.models import ExportedSummaryFile, FileSummaryBatch +logger = logging.getLogger("review_agent.file_summary.report") + + def _exports_dir(batch: FileSummaryBatch) -> Path: root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "file_summary" / batch.batch_no export_dir = root / "exports" @@ -55,6 +59,7 @@ def build_markdown_report(batch: FileSummaryBatch) -> str: def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFile, str]: + logger.info("Markdown report generation started", extra={"batch_id": batch.pk}) content = build_markdown_report(batch) path = _exports_dir(batch) / f"{batch.batch_no}-summary.md" path.write_text(content, encoding="utf-8") @@ -64,4 +69,8 @@ def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFi file_name=path.name, storage_path=str(path), ) + logger.info( + "Markdown report generation finished", + extra={"batch_id": batch.pk, "export_id": exported.pk, "path": str(path)}, + ) return exported, build_summary_table(batch) diff --git a/review_agent/file_summary/skills/archive_extract.py b/review_agent/file_summary/skills/archive_extract.py index 83487b8..6e12f6f 100644 --- a/review_agent/file_summary/skills/archive_extract.py +++ b/review_agent/file_summary/skills/archive_extract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from review_agent.models import FileSummaryBatchAttachment @@ -9,6 +10,9 @@ from ..services.archive import ARCHIVE_EXTENSIONS, extract_archive from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.archive_extract") + + class ArchiveExtractSkill(BaseSkill): name = "archive_extract" @@ -16,11 +20,27 @@ class ArchiveExtractSkill(BaseSkill): extracted_count = 0 target_dir = Path(context.batch.work_dir or "") if not target_dir: + logger.info( + "Archive extract skipped without work dir", + extra={"batch_id": context.batch.pk, "batch_no": context.batch.batch_no}, + ) return SkillResult(success=True, data={"extracted_count": 0}) for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch): path = resolve_storage_path(binding.attachment.storage_path) if path.suffix.lower().lstrip(".") not in ARCHIVE_EXTENSIONS: continue + logger.info( + "Archive extract started", + extra={ + "batch_id": context.batch.pk, + "attachment_id": binding.attachment_id, + "path": str(path), + }, + ) extracted_count += len(extract_archive(path, target_dir)) + logger.info( + "Archive extract finished", + extra={"batch_id": context.batch.pk, "extracted_count": extracted_count}, + ) return SkillResult(success=True, data={"extracted_count": extracted_count}) diff --git a/review_agent/file_summary/skills/attachment_reader.py b/review_agent/file_summary/skills/attachment_reader.py index 3ce5cff..1ebdf5c 100644 --- a/review_agent/file_summary/skills/attachment_reader.py +++ b/review_agent/file_summary/skills/attachment_reader.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from collections.abc import Iterable from review_agent.models import FileAttachment @@ -8,6 +9,9 @@ from ..services.attachment_reader import read_attachment_details from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.attachment_reader") + + class AttachmentReaderSkill(BaseSkill): name = "attachment_reader" @@ -19,11 +23,28 @@ class AttachmentReaderSkill(BaseSkill): return self.run_for_attachments(attachments) def run_for_attachments(self, attachments: Iterable[FileAttachment]) -> SkillResult: - results = [read_attachment_details(attachment).to_dict() for attachment in attachments] + attachment_list = list(attachments) + logger.info( + "Attachment reader skill started", + extra={ + "attachment_count": len(attachment_list), + "attachment_ids": [attachment.pk for attachment in attachment_list], + }, + ) + results = [read_attachment_details(attachment).to_dict() for attachment in attachment_list] if not results: + logger.warning("Attachment reader skill found no attachments") return SkillResult(success=False, message="当前对话没有可读取的附件。") has_success = any(item["status"] == "success" for item in results) + logger.info( + "Attachment reader skill finished", + extra={ + "success": has_success, + "success_count": sum(1 for item in results if item["status"] == "success"), + "failed_count": sum(1 for item in results if item["status"] != "success"), + }, + ) return SkillResult( success=has_success, data={"attachments": results}, diff --git a/review_agent/file_summary/skills/document_page_count.py b/review_agent/file_summary/skills/document_page_count.py index f53ad77..5b4e4e4 100644 --- a/review_agent/file_summary/skills/document_page_count.py +++ b/review_agent/file_summary/skills/document_page_count.py @@ -1,25 +1,49 @@ from __future__ import annotations +import logging + from review_agent.models import FileSummaryItem from ..services.page_count import SUPPORTED_EXTENSIONS, count_document_pages from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.document_page_count") + + class DocumentPageCountSkill(BaseSkill): name = "document_page_count" def run(self, context: WorkflowContext) -> SkillResult: success_files = failed_files = unsupported_files = uncertain_files = total_pages = 0 + logger.info("Document page count started", extra={"batch_id": context.batch.pk}) for item in context.batch.items.order_by("file_index"): if item.file_type not in SUPPORTED_EXTENSIONS: item.statistics_status = FileSummaryItem.StatisticsStatus.UNSUPPORTED unsupported_files += 1 item.save(update_fields=["statistics_status", "updated_at"]) + logger.info( + "Document page count unsupported", + extra={ + "batch_id": context.batch.pk, + "item_id": item.pk, + "file_type": item.file_type, + "file_name": item.file_name, + }, + ) continue result = None for attempt in range(1, 4): + logger.info( + "Document page count attempt", + extra={ + "batch_id": context.batch.pk, + "item_id": item.pk, + "attempt": attempt, + "storage_path": item.storage_path, + }, + ) result = count_document_pages(item.storage_path) item.retry_count = attempt - 1 if result.status != "failed": @@ -46,6 +70,15 @@ class DocumentPageCountSkill(BaseSkill): unsupported_files += 1 else: failed_files += 1 + logger.warning( + "Document page count failed", + extra={ + "batch_id": context.batch.pk, + "item_id": item.pk, + "file_name": item.file_name, + "error": result.error_message, + }, + ) context.batch.success_files = success_files context.batch.failed_files = failed_files @@ -61,4 +94,15 @@ class DocumentPageCountSkill(BaseSkill): "total_pages", ] ) + logger.info( + "Document page count finished", + extra={ + "batch_id": context.batch.pk, + "success_files": success_files, + "failed_files": failed_files, + "unsupported_files": unsupported_files, + "uncertain_files": uncertain_files, + "total_pages": total_pages, + }, + ) return SkillResult(success=True) diff --git a/review_agent/file_summary/skills/file_inventory.py b/review_agent/file_summary/skills/file_inventory.py index 75a94dc..a705e9f 100644 --- a/review_agent/file_summary/skills/file_inventory.py +++ b/review_agent/file_summary/skills/file_inventory.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from review_agent.models import FileSummaryBatchAttachment @@ -9,6 +10,9 @@ from ..services.inventory import scan_files_to_items from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.file_inventory") + + class FileInventorySkill(BaseSkill): name = "file_inventory" @@ -17,5 +21,17 @@ class FileInventorySkill(BaseSkill): resolve_storage_path(binding.attachment.storage_path) for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch) ] + logger.info( + "File inventory started", + extra={ + "batch_id": context.batch.pk, + "root_count": len(roots), + "roots": [str(root) for root in roots], + }, + ) items = scan_files_to_items(batch=context.batch, roots=roots) + logger.info( + "File inventory finished", + extra={"batch_id": context.batch.pk, "total_files": len(items)}, + ) return SkillResult(success=True, data={"total_files": len(items)}) diff --git a/review_agent/file_summary/skills/product_detect.py b/review_agent/file_summary/skills/product_detect.py index cf86b63..188b84c 100644 --- a/review_agent/file_summary/skills/product_detect.py +++ b/review_agent/file_summary/skills/product_detect.py @@ -1,12 +1,22 @@ from __future__ import annotations +import logging + from ..services.product_detect import detect_product_name from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.product_detect") + + class ProductDetectSkill(BaseSkill): name = "product_detect" def run(self, context: WorkflowContext) -> SkillResult: + logger.info("Product detect started", extra={"batch_id": context.batch.pk}) product_name = detect_product_name(context.batch) + logger.info( + "Product detect finished", + extra={"batch_id": context.batch.pk, "product_name": product_name}, + ) return SkillResult(success=True, data={"product_name": product_name}) diff --git a/review_agent/file_summary/skills/registry.py b/review_agent/file_summary/skills/registry.py index 9dde1e7..b49a614 100644 --- a/review_agent/file_summary/skills/registry.py +++ b/review_agent/file_summary/skills/registry.py @@ -1,8 +1,13 @@ from __future__ import annotations +import logging + from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills") + + class SkillRegistry: def __init__(self): self._skills: dict[str, BaseSkill] = {} @@ -11,6 +16,7 @@ class SkillRegistry: if not skill.name: raise ValueError("Skill 必须声明 name。") self._skills[skill.name] = skill + logger.info("Skill registered: %s", skill.name, extra={"skill_name": skill.name}) def get(self, name: str) -> BaseSkill: try: @@ -19,4 +25,20 @@ class SkillRegistry: raise KeyError(f"Skill 未注册:{name}") from exc def execute(self, name: str, context: WorkflowContext) -> SkillResult: - return self.get(name).run(context) + logger.info("Skill started: %s", name, extra={"skill_name": name, "batch_id": context.batch.pk}) + try: + result = self.get(name).run(context) + except Exception: + logger.exception("Skill crashed: %s", name, extra={"skill_name": name, "batch_id": context.batch.pk}) + raise + logger.info( + "Skill finished: %s", + name, + extra={ + "skill_name": name, + "batch_id": context.batch.pk, + "success": result.success, + "result_message": result.message, + }, + ) + return result diff --git a/review_agent/file_summary/skills/summary_report.py b/review_agent/file_summary/skills/summary_report.py index 3e0c043..c70cdf9 100644 --- a/review_agent/file_summary/skills/summary_report.py +++ b/review_agent/file_summary/skills/summary_report.py @@ -1,5 +1,7 @@ from __future__ import annotations +import logging + from django.urls import reverse from review_agent.models import Message @@ -9,10 +11,14 @@ from ..services.report import generate_markdown_report from .base import BaseSkill, SkillResult, WorkflowContext +logger = logging.getLogger("review_agent.file_summary.skills.summary_report") + + class SummaryReportSkill(BaseSkill): name = "summary_report" def run(self, context: WorkflowContext) -> SkillResult: + logger.info("Summary report started", extra={"batch_id": context.batch.pk}) markdown_export, summary_table = generate_markdown_report(context.batch) excel_export = generate_excel_export(context.batch) markdown_url = reverse("file_summary_export_download", args=[markdown_export.pk]) @@ -27,6 +33,14 @@ class SummaryReportSkill(BaseSkill): role=Message.Role.ASSISTANT, content=content, ) + logger.info( + "Summary report finished", + extra={ + "batch_id": context.batch.pk, + "markdown_export_id": markdown_export.pk, + "excel_export_id": excel_export.pk, + }, + ) return SkillResult( success=True, data={"markdown_export_id": markdown_export.pk, "excel_export_id": excel_export.pk}, diff --git a/review_agent/file_summary/storage.py b/review_agent/file_summary/storage.py index 7c2a0c7..413c768 100644 --- a/review_agent/file_summary/storage.py +++ b/review_agent/file_summary/storage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from pathlib import Path from uuid import uuid4 @@ -12,6 +13,9 @@ from review_agent.models import Conversation, FileAttachment from .constants import ATTACHMENT_ROOT +logger = logging.getLogger("review_agent.file_summary.storage") + + def _safe_original_name(name: str) -> str: clean = get_valid_filename(Path(name).name) return clean or f"upload-{uuid4().hex}" @@ -42,6 +46,16 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) """Stores an uploaded file and creates a versioned attachment record.""" original_name = _safe_original_name(uploaded_file.name) + logger.info( + "Attachment upload save started", + extra={ + "conversation_id": conversation.pk, + "user_id": user.pk, + "original_name": original_name, + "file_size": uploaded_file.size, + "content_type": getattr(uploaded_file, "content_type", "") or "", + }, + ) latest = ( FileAttachment.objects.filter(conversation=conversation, original_name=original_name) .order_by("-version_no") @@ -63,7 +77,7 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) is_active=True, ).update(is_active=False) - return FileAttachment.objects.create( + attachment = FileAttachment.objects.create( conversation=conversation, user=user, original_name=original_name, @@ -73,6 +87,16 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file) file_size=uploaded_file.size, content_type=getattr(uploaded_file, "content_type", "") or "", ) + logger.info( + "Attachment upload save finished", + extra={ + "conversation_id": conversation.pk, + "attachment_id": attachment.pk, + "version_no": attachment.version_no, + "storage_path": attachment.storage_path, + }, + ) + return attachment def serialize_attachment(attachment: FileAttachment) -> dict[str, object]: diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index c32a688..a8a57b1 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -1,4 +1,5 @@ from django.contrib.auth.decorators import login_required +import logging from pathlib import Path from django.http import FileResponse, Http404, JsonResponse @@ -11,6 +12,9 @@ from .events import serialize_event from .storage import save_uploaded_attachment, serialize_attachment +logger = logging.getLogger("review_agent.file_summary.views") + + def _conversation_for_user(user, conversation_id: int) -> Conversation: conversation = Conversation.objects.filter(pk=conversation_id, user=user).first() if not conversation: @@ -27,6 +31,15 @@ def attachments(request, conversation_id: int): files = request.FILES.getlist("files") if not files: return JsonResponse({"error": "请选择至少一个文件。"}, status=400) + logger.info( + "Attachment upload request received", + extra={ + "conversation_id": conversation.pk, + "user_id": request.user.pk, + "file_count": len(files), + "filenames": [uploaded_file.name for uploaded_file in files], + }, + ) saved = [ save_uploaded_attachment( conversation=conversation, @@ -35,12 +48,23 @@ def attachments(request, conversation_id: int): ) for uploaded_file in files ] + logger.info( + "Attachment upload request finished", + extra={ + "conversation_id": conversation.pk, + "attachment_ids": [attachment.pk for attachment in saved], + }, + ) return JsonResponse({"attachments": [serialize_attachment(item) for item in saved]}) queryset = FileAttachment.objects.filter(conversation=conversation).order_by( "original_name", "-version_no", ) + logger.info( + "Attachment list requested", + extra={"conversation_id": conversation.pk, "attachment_count": queryset.count()}, + ) return JsonResponse({"attachments": [serialize_attachment(item) for item in queryset]}) @@ -59,6 +83,10 @@ def attachment_detail(request, conversation_id: int, attachment_id: int): attachment.upload_status = FileAttachment.UploadStatus.DELETED attachment.is_active = False attachment.save(update_fields=["upload_status", "is_active"]) + logger.info( + "Attachment deleted", + extra={"conversation_id": conversation.pk, "attachment_id": attachment.pk}, + ) return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)}) @@ -120,12 +148,25 @@ def export_download(request, export_id: int): raise Http404("导出文件不存在。") path = Path(exported.storage_path) if not path.exists(): + logger.warning( + "Export download missing file", + extra={"export_id": exported.pk, "storage_path": exported.storage_path}, + ) return JsonResponse({"error": "文件不存在。"}, status=404) content_type = ( "text/markdown; charset=utf-8" if exported.export_type == ExportedSummaryFile.ExportType.MARKDOWN else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) + logger.info( + "Export download started", + extra={ + "export_id": exported.pk, + "batch_id": exported.batch_id, + "file_name": exported.file_name, + "content_type": content_type, + }, + ) return FileResponse( path.open("rb"), as_attachment=True, diff --git a/review_agent/file_summary/workflow.py b/review_agent/file_summary/workflow.py index 050ee88..8bfa147 100644 --- a/review_agent/file_summary/workflow.py +++ b/review_agent/file_summary/workflow.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging from threading import Thread from uuid import uuid4 @@ -36,6 +37,9 @@ NODE_DEFINITIONS = [ ] +logger = logging.getLogger("review_agent.file_summary.workflow") + + def default_skill_registry() -> SkillRegistry: registry = SkillRegistry() registry.register(ArchiveExtractSkill()) @@ -65,6 +69,14 @@ def create_file_summary_batch( ) if not active_attachments: raise ValueError("当前对话没有可用附件。") + logger.info( + "File summary batch creation started", + extra={ + "conversation_id": conversation.pk, + "user_id": user.pk, + "attachment_ids": [attachment.pk for attachment in active_attachments], + }, + ) batch = FileSummaryBatch.objects.create( conversation=conversation, @@ -82,6 +94,10 @@ def create_file_summary_batch( WorkflowNodeRun.objects.create(batch=batch, node_code=code, node_name=name) record_event(batch, "workflow_created", {"batch_id": batch.pk, "batch_no": batch.batch_no}) + logger.info( + "File summary batch created", + extra={"batch_id": batch.pk, "batch_no": batch.batch_no}, + ) return batch @@ -91,6 +107,7 @@ class WorkflowExecutor: self.registry = registry or default_skill_registry() def run(self) -> None: + logger.info("Workflow run started", extra={"batch_id": self.batch.pk}) self.batch.status = FileSummaryBatch.Status.RUNNING self.batch.started_at = timezone.now() self.batch.save(update_fields=["status", "started_at"]) @@ -100,6 +117,10 @@ class WorkflowExecutor: for node in self.batch.node_runs.order_by("id"): self._run_node(node) except Exception as exc: + logger.exception( + "Workflow run failed", + extra={"batch_id": self.batch.pk, "error": str(exc)}, + ) self.batch.status = FileSummaryBatch.Status.FAILED self.batch.error_message = str(exc) self.batch.finished_at = timezone.now() @@ -111,8 +132,17 @@ class WorkflowExecutor: self.batch.finished_at = timezone.now() self.batch.save(update_fields=["status", "finished_at"]) record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk}) + logger.info("Workflow run completed", extra={"batch_id": self.batch.pk}) def _run_node(self, node: WorkflowNodeRun) -> None: + logger.info( + "Workflow node started", + extra={ + "batch_id": self.batch.pk, + "node_code": node.node_code, + "node_name": node.node_name, + }, + ) now = timezone.now() node.status = WorkflowNodeRun.Status.RUNNING node.progress = 10 @@ -132,6 +162,15 @@ class WorkflowExecutor: if skill_name: result = self.registry.execute(skill_name, WorkflowContext(batch=self.batch)) if not result.success: + logger.warning( + "Workflow node skill failed", + extra={ + "batch_id": self.batch.pk, + "node_code": node.node_code, + "skill_name": skill_name, + "result_message": result.message, + }, + ) raise RuntimeError(result.message or f"{node.node_name}执行失败") node.status = WorkflowNodeRun.Status.SUCCESS @@ -144,11 +183,17 @@ class WorkflowExecutor: "node_progress", {"node_code": node.node_code, "status": node.status, "progress": node.progress}, ) + logger.info( + "Workflow node finished", + extra={"batch_id": self.batch.pk, "node_code": node.node_code}, + ) def start_file_summary_workflow(batch: FileSummaryBatch, *, async_run: bool = True) -> None: executor = WorkflowExecutor(batch) if not async_run: + logger.info("Workflow starting synchronously", extra={"batch_id": batch.pk}) executor.run() return + logger.info("Workflow starting asynchronously", extra={"batch_id": batch.pk}) Thread(target=executor.run, daemon=True).start() diff --git a/review_agent/file_summary/workflow_trigger.py b/review_agent/file_summary/workflow_trigger.py index 8e1722e..cb53efe 100644 --- a/review_agent/file_summary/workflow_trigger.py +++ b/review_agent/file_summary/workflow_trigger.py @@ -14,11 +14,38 @@ ATTACHMENT_READER_KEYWORDS = ( "查看附件", "附件详情", "文件详情", + "文件内容", + "附件内容", + "简历文件", + "提供的文件", + "提供的简历", + "上传的文件", + "上传文件", + "这个文件", + "该文件", "总结附件", "总结文件", "分析这个文件", "阅读这个文件", ) +ATTACHMENT_REFERENCE_KEYWORDS = ("附件", "文件", "简历", "上传") +ATTACHMENT_READ_INTENT_KEYWORDS = ( + "阅读", + "读取", + "读", + "解析", + "分析", + "查看", + "提取", + "整理", + "总结", + "介绍", + "项目经历", + "工作经历", + "经历", + "信息", + "内容", +) @dataclass(frozen=True) @@ -45,7 +72,11 @@ def evaluate_file_summary_trigger(conversation: Conversation, content: str) -> T def evaluate_attachment_reader_trigger(conversation: Conversation, content: str) -> TriggerResult: text = (content or "").strip() - if not any(keyword in text for keyword in ATTACHMENT_READER_KEYWORDS): + matched = any(keyword in text for keyword in ATTACHMENT_READER_KEYWORDS) or ( + any(keyword in text for keyword in ATTACHMENT_REFERENCE_KEYWORDS) + and any(keyword in text for keyword in ATTACHMENT_READ_INTENT_KEYWORDS) + ) + if not matched: return TriggerResult(should_start=False, reason="not_matched") has_attachment = FileAttachment.objects.filter( diff --git a/review_agent/llm.py b/review_agent/llm.py index 6680f84..6c7def7 100644 --- a/review_agent/llm.py +++ b/review_agent/llm.py @@ -53,6 +53,47 @@ def generate_reply(conversation, user_message: str) -> str: raise LLMRequestError("模型接口返回格式不符合预期。") from exc +def generate_completion(messages: list[dict[str, str]], *, temperature: float = 0.0) -> str: + """Calls the configured chat endpoint with explicit messages and returns assistant text.""" + + if not settings.LLM_API_KEY: + raise LLMConfigurationError("缺少 LLM_API_KEY 配置。") + if not settings.LLM_MODEL: + raise LLMConfigurationError("缺少 LLM_MODEL 配置。") + + payload = { + "model": settings.LLM_MODEL, + "messages": messages, + "temperature": temperature, + } + body = json.dumps(payload).encode("utf-8") + endpoint = f"{settings.LLM_BASE_URL.rstrip('/')}/chat/completions" + + http_request = request.Request( + endpoint, + data=body, + headers={ + "Authorization": f"Bearer {settings.LLM_API_KEY}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with request.urlopen(http_request, timeout=60) as response: + data = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="ignore") + raise LLMRequestError(f"模型接口调用失败:HTTP {exc.code} {details}") from exc + except error.URLError as exc: + raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc + + try: + return data["choices"][0]["message"]["content"].strip() + except (KeyError, IndexError, TypeError) as exc: + raise LLMRequestError("模型接口返回格式不符合预期。") from exc + + def stream_reply(conversation, user_message: str): """Streams incremental assistant text from the SiliconFlow chat endpoint.""" diff --git a/review_agent/services.py b/review_agent/services.py index f29880f..3d3f720 100644 --- a/review_agent/services.py +++ b/review_agent/services.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from django.db.models import Q, QuerySet from django.conf import settings @@ -8,12 +9,12 @@ from django.utils import timezone from .file_summary.skills.attachment_reader import AttachmentReaderSkill from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow -from .file_summary.workflow_trigger import ( - evaluate_attachment_reader_trigger, - evaluate_file_summary_trigger, -) from .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply from .models import Conversation, FileAttachment, Message +from .skill_router import route_message_intent + + +logger = logging.getLogger(__name__) def list_conversations(user, search: str = "") -> QuerySet[Conversation]: @@ -54,6 +55,14 @@ def append_user_message(conversation: Conversation, content: str) -> Message: role=Message.Role.USER, content=content.strip(), ) + logger.info( + "User message appended", + extra={ + "conversation_id": conversation.pk, + "message_id": message.pk, + "content_length": len(message.content), + }, + ) if conversation.messages.filter(role=Message.Role.USER).count() == 1: conversation.title = build_conversation_title(content) @@ -65,11 +74,20 @@ def append_user_message(conversation: Conversation, content: str) -> Message: def append_assistant_message(conversation: Conversation, content: str) -> Message: """Appends the deterministic assistant reply.""" - return Message.objects.create( + message = Message.objects.create( conversation=conversation, role=Message.Role.ASSISTANT, content=content, ) + logger.info( + "Assistant message appended", + extra={ + "conversation_id": conversation.pk, + "message_id": message.pk, + "content_length": len(content or ""), + }, + ) + return message def send_message(conversation: Conversation, content: str) -> tuple[Message, Message]: @@ -95,8 +113,18 @@ def stream_message(conversation: Conversation, content: str): user_message = append_user_message(conversation, content) assistant_parts: list[str] = [] - trigger = evaluate_file_summary_trigger(conversation, content) - attachment_reader_trigger = evaluate_attachment_reader_trigger(conversation, content) + route = route_message_intent(conversation, content) + logger.info( + "Stream message started", + extra={ + "conversation_id": conversation.pk, + "user_message_id": user_message.pk, + "route_action": route.action, + "route_source": route.source, + "route_confidence": route.confidence, + "route_reason": route.reason, + }, + ) yield sse_event( "meta", @@ -108,7 +136,7 @@ def stream_message(conversation: Conversation, content: str): }, ) - if trigger.reason == "missing_attachment": + if route.starts_file_summary and not _has_active_attachments(conversation): reply_content = "请先在当前对话右侧上传需要汇总的文件或压缩包,然后再发送自动汇总指令。" assistant_message = append_assistant_message(conversation, reply_content) yield sse_event("chunk", {"delta": reply_content}) @@ -122,7 +150,7 @@ def stream_message(conversation: Conversation, content: str): ) return - if attachment_reader_trigger.reason == "missing_attachment": + if route.uses_attachment_reader and not _has_active_attachments(conversation): reply_content = "请先在当前对话右侧上传需要阅读的附件,然后再发送解析或阅读附件指令。" assistant_message = append_assistant_message(conversation, reply_content) yield sse_event("chunk", {"delta": reply_content}) @@ -136,8 +164,16 @@ def stream_message(conversation: Conversation, content: str): ) return - if attachment_reader_trigger.should_start: + if route.uses_attachment_reader: attachments = _select_attachments_for_reader(conversation, content) + logger.info( + "Attachment reader path selected", + extra={ + "conversation_id": conversation.pk, + "attachment_count": len(attachments), + "attachment_ids": [attachment.pk for attachment in attachments], + }, + ) result = AttachmentReaderSkill().run_for_attachments(attachments) reply_content = _format_attachment_reader_reply(result.data.get("attachments", []), result.message) assistant_message = append_assistant_message(conversation, reply_content) @@ -152,7 +188,7 @@ def stream_message(conversation: Conversation, content: str): ) return - if trigger.should_start: + if route.starts_file_summary: batch = create_file_summary_batch( conversation=conversation, user=conversation.user, @@ -190,6 +226,18 @@ def stream_message(conversation: Conversation, content: str): except (LLMConfigurationError, LLMRequestError) as exc: fallback = f"模型调用失败:{exc}" assistant_parts = [fallback] + logger.warning( + "LLM stream failed", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) + yield sse_event("error", {"message": fallback}) + except Exception as exc: + fallback = f"回复生成中断:{exc}" + assistant_parts.append("\n\n" + fallback) + logger.exception( + "Unexpected stream failure", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) yield sse_event("error", {"message": fallback}) assistant_message = append_assistant_message(conversation, "".join(assistant_parts).strip()) @@ -230,6 +278,14 @@ def _select_attachments_for_reader(conversation: Conversation, content: str): return matched or attachments +def _has_active_attachments(conversation: Conversation) -> bool: + return ( + FileAttachment.objects.filter(conversation=conversation, is_active=True) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .exists() + ) + + def _format_attachment_reader_reply(attachments: list[dict[str, object]], message: str) -> str: if not attachments: return message or "当前对话没有可读取的附件。" diff --git a/review_agent/skill_router.py b/review_agent/skill_router.py new file mode 100644 index 0000000..d81ebbc --- /dev/null +++ b/review_agent/skill_router.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass + +from .file_summary.workflow_trigger import ( + evaluate_attachment_reader_trigger, + evaluate_file_summary_trigger, +) +from .llm import LLMConfigurationError, LLMRequestError, generate_completion +from .models import Conversation, FileAttachment + + +logger = logging.getLogger(__name__) + +ROUTE_ACTIONS = {"normal_chat", "attachment_reader", "file_summary"} + + +@dataclass(frozen=True) +class SkillRoute: + action: str + skill_name: str = "" + workflow_type: str = "" + confidence: float = 0.0 + reason: str = "" + source: str = "llm" + + @property + def uses_attachment_reader(self) -> bool: + return self.action == "attachment_reader" + + @property + def starts_file_summary(self) -> bool: + return self.action == "file_summary" + + @property + def is_normal_chat(self) -> bool: + return self.action == "normal_chat" + + +def route_message_intent(conversation: Conversation, content: str) -> SkillRoute: + attachments = list(_active_attachments(conversation)) + try: + route = _route_with_llm(conversation, content, attachments) + logger.info( + "LLM skill route selected", + extra={ + "conversation_id": conversation.pk, + "action": route.action, + "skill_name": route.skill_name, + "workflow_type": route.workflow_type, + "confidence": route.confidence, + "route_source": route.source, + "reason": route.reason, + }, + ) + return route + except (LLMConfigurationError, LLMRequestError, ValueError, json.JSONDecodeError) as exc: + logger.warning( + "LLM skill route failed, fallback to rules", + extra={"conversation_id": conversation.pk, "error": str(exc)}, + ) + return _route_with_rules(conversation, content) + + +def _route_with_llm( + conversation: Conversation, + content: str, + attachments: list[FileAttachment], +) -> SkillRoute: + raw = generate_completion( + [ + {"role": "system", "content": _router_system_prompt()}, + { + "role": "user", + "content": _router_user_prompt( + user_message=content, + attachments=attachments, + ), + }, + ], + temperature=0.0, + ) + payload = _parse_json_object(raw) + action = str(payload.get("action", "normal_chat")).strip() + if action not in ROUTE_ACTIONS: + raise ValueError(f"不支持的路由动作:{action}") + + if action in {"attachment_reader", "file_summary"} and not attachments: + return SkillRoute( + action=action, + skill_name="attachment_reader" if action == "attachment_reader" else "", + workflow_type="file_summary" if action == "file_summary" else "", + confidence=_float_or_zero(payload.get("confidence")), + reason=str(payload.get("reason") or "LLM 判断需要附件,但当前无附件。"), + source="llm_missing_attachment", + ) + + return SkillRoute( + action=action, + skill_name="attachment_reader" if action == "attachment_reader" else "", + workflow_type="file_summary" if action == "file_summary" else "", + confidence=_float_or_zero(payload.get("confidence")), + reason=str(payload.get("reason") or ""), + source="llm", + ) + + +def _route_with_rules(conversation: Conversation, content: str) -> SkillRoute: + file_summary = evaluate_file_summary_trigger(conversation, content) + if file_summary.should_start or file_summary.reason == "missing_attachment": + return SkillRoute( + action="file_summary", + workflow_type="file_summary", + confidence=0.5, + reason=file_summary.reason, + source="rule_fallback", + ) + + attachment_reader = evaluate_attachment_reader_trigger(conversation, content) + if attachment_reader.should_start or attachment_reader.reason == "missing_attachment": + return SkillRoute( + action="attachment_reader", + skill_name="attachment_reader", + confidence=0.5, + reason=attachment_reader.reason, + source="rule_fallback", + ) + + return SkillRoute( + action="normal_chat", + confidence=0.5, + reason="未匹配到需要调用 Skill 或工作流的意图。", + source="rule_fallback", + ) + + +def _active_attachments(conversation: Conversation): + return ( + FileAttachment.objects.filter(conversation=conversation, is_active=True) + .exclude(upload_status=FileAttachment.UploadStatus.DELETED) + .order_by("original_name", "-version_no") + ) + + +def _router_system_prompt() -> str: + return ( + "你是审核智能体的工具路由器,只判断是否需要调用工具,不直接回答用户。" + "你必须只输出 JSON 对象,不要输出 Markdown。" + "可选 action:normal_chat、attachment_reader、file_summary。" + "attachment_reader 用于用户要求阅读、提取、分析、总结、查看上传附件内容。" + "file_summary 用于用户要求自动汇总文件目录、页数、清单或生成目录页数报告。" + "normal_chat 用于不需要读取附件或执行工作流的一般问答。" + "输出字段:action、confidence、reason。" + ) + + +def _router_user_prompt(*, user_message: str, attachments: list[FileAttachment]) -> str: + attachment_lines = [ + f"- id={attachment.pk}, name={attachment.original_name}, active={attachment.is_active}, status={attachment.upload_status}" + for attachment in attachments + ] + attachment_text = "\n".join(attachment_lines) if attachment_lines else "无 active 附件" + return ( + f"用户消息:{user_message}\n\n" + f"当前 active 附件:\n{attachment_text}\n\n" + "请判断应调用哪个 action。只输出 JSON。" + ) + + +def _parse_json_object(raw: str) -> dict: + text = (raw or "").strip() + if text.startswith("```"): + text = text.strip("`").strip() + if text.lower().startswith("json"): + text = text[4:].strip() + start = text.find("{") + end = text.rfind("}") + if start == -1 or end == -1 or end < start: + raise json.JSONDecodeError("未找到 JSON 对象", text, 0) + return json.loads(text[start : end + 1]) + + +def _float_or_zero(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 diff --git a/tests/test_file_summary_skills.py b/tests/test_file_summary_skills.py index a700155..ba3daf1 100644 --- a/tests/test_file_summary_skills.py +++ b/tests/test_file_summary_skills.py @@ -1,4 +1,5 @@ import pytest +import logging from review_agent.file_summary.skills.base import BaseSkill, SkillResult, WorkflowContext from review_agent.file_summary.skills.registry import SkillRegistry @@ -25,3 +26,21 @@ def test_skill_registry_executes_registered_skill(django_user_model): assert result.success is True assert result.data == {"batch_id": batch.id} + + +@pytest.mark.django_db +def test_skill_registry_logs_skill_lifecycle(caplog, django_user_model): + from review_agent.models import Conversation, FileSummaryBatch + + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-LOG") + registry = SkillRegistry() + registry.register(EchoSkill()) + + with caplog.at_level(logging.INFO, logger="review_agent.file_summary"): + registry.execute("echo", WorkflowContext(batch=batch)) + + messages = [record.getMessage() for record in caplog.records] + assert any("Skill started" in message and "echo" in message for message in messages) + assert any("Skill finished" in message and "echo" in message for message in messages) diff --git a/tests/test_file_summary_trigger.py b/tests/test_file_summary_trigger.py index 4d94164..ad0c8c3 100644 --- a/tests/test_file_summary_trigger.py +++ b/tests/test_file_summary_trigger.py @@ -1,6 +1,9 @@ import pytest -from review_agent.file_summary.workflow_trigger import evaluate_file_summary_trigger +from review_agent.file_summary.workflow_trigger import ( + evaluate_attachment_reader_trigger, + evaluate_file_summary_trigger, +) from review_agent.models import Conversation, FileAttachment @@ -30,3 +33,41 @@ def test_trigger_matches_keywords_only_when_active_attachment_exists(django_user normal = evaluate_file_summary_trigger(conversation, "你好,帮我解释法规") assert normal.should_start is False assert normal.reason == "not_matched" + + +def test_attachment_reader_trigger_matches_file_content_phrases(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + missing = evaluate_attachment_reader_trigger(conversation, "根据提供的简历文件内容,简要介绍") + assert missing.should_start is False + assert missing.reason == "missing_attachment" + + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="resume.docx", + storage_path="x/resume.docx", + file_size=1, + ) + + matched = evaluate_attachment_reader_trigger(conversation, "根据提供的简历文件内容,简要介绍") + assert matched.should_start is True + assert matched.workflow_type == "attachment_reader" + + +def test_attachment_reader_trigger_matches_resume_project_experience_request(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="resume.docx", + storage_path="x/resume.docx", + file_size=1, + ) + + matched = evaluate_attachment_reader_trigger(conversation, "阅读下附件简历中的项目经历") + + assert matched.should_start is True + assert matched.workflow_type == "attachment_reader" diff --git a/tests/test_file_summary_workflow.py b/tests/test_file_summary_workflow.py index 57534a5..b80e490 100644 --- a/tests/test_file_summary_workflow.py +++ b/tests/test_file_summary_workflow.py @@ -1,6 +1,7 @@ import pytest from review_agent.file_summary.workflow import create_file_summary_batch, start_file_summary_workflow +from review_agent.skill_router import SkillRoute from review_agent.models import ( Conversation, FileAttachment, @@ -102,6 +103,21 @@ def test_stream_message_uses_normal_llm_path_when_not_triggered(monkeypatch, dja assert "workflow_started" not in joined +def test_stream_message_meta_uses_first_prompt_title_for_new_conversation(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="新对话 01-01 10:00") + + def fake_stream_reply(conversation, content): + yield "普通回复" + + monkeypatch.setattr("review_agent.services.stream_reply", fake_stream_reply) + + frames = list(stream_message(conversation, "这是第一条新对话消息")) + + assert '"title": "这是第一条新对话消息"' in frames[0] + assert '"title": "这是第一条新对话消息"' in frames[-1] + + def test_stream_message_reads_active_attachment_when_requested(settings, tmp_path, django_user_model): settings.MEDIA_ROOT = tmp_path user = django_user_model.objects.create_user(username="owner", password="pass") @@ -124,3 +140,91 @@ def test_stream_message_reads_active_attachment_when_requested(settings, tmp_pat assert "detail.txt" in joined assert "RA-2026" in joined assert "workflow_started" not in joined + + +def test_stream_message_returns_error_event_when_unexpected_stream_error(monkeypatch, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + + def broken_stream_reply(conversation, content): + yield "已生成部分内容" + raise RuntimeError("provider connection reset") + + monkeypatch.setattr("review_agent.services.stream_reply", broken_stream_reply) + + frames = list(stream_message(conversation, "普通问题")) + + joined = "".join(frames) + assert "已生成部分内容" in joined + assert "回复生成中断" in joined + assert "done" in joined + assert Message.objects.filter(conversation=conversation, role=Message.Role.ASSISTANT).exists() + + +def test_stream_message_uses_llm_router_for_attachment_reader( + monkeypatch, + settings, + tmp_path, + django_user_model, +): + settings.MEDIA_ROOT = tmp_path + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + attachment_path = tmp_path / "uploads" / "resume.txt" + attachment_path.parent.mkdir(parents=True) + attachment_path.write_text("项目经历:负责审核智能体附件解析模块。", encoding="utf-8") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="resume.txt", + storage_path="uploads/resume.txt", + file_size=attachment_path.stat().st_size, + ) + + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="attachment_reader", + skill_name="attachment_reader", + confidence=0.91, + reason="需要读取上传简历。", + source="llm", + ), + ) + + frames = list(stream_message(conversation, "帮我整理其中的项目经历")) + + joined = "".join(frames) + assert "附件解析结果" in joined + assert "审核智能体附件解析模块" in joined + assert "模型调用失败" not in joined + + +def test_stream_message_uses_llm_router_for_file_summary(monkeypatch, settings, django_user_model): + settings.FILE_SUMMARY_ASYNC = False + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + FileAttachment.objects.create( + conversation=conversation, + user=user, + original_name="a.docx", + storage_path="x/a.docx", + file_size=1, + ) + monkeypatch.setattr( + "review_agent.services.route_message_intent", + lambda conversation, content: SkillRoute( + action="file_summary", + workflow_type="file_summary", + confidence=0.93, + reason="需要执行文件目录与页数汇总。", + source="llm", + ), + ) + + frames = list(stream_message(conversation, "处理一下这批资料")) + + joined = "".join(frames) + assert "workflow_started" in joined + assert "\"workflow_type\": \"file_summary\"" in joined + assert FileSummaryBatch.objects.filter(conversation=conversation).exists()