feat(agent): 增加 LLM 路由与诊断日志
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user