feat(agent): 增加 LLM 路由与诊断日志
This commit is contained in:
@@ -104,3 +104,26 @@ LOGOUT_REDIRECT_URL = "login"
|
|||||||
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
|
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
|
||||||
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.siliconflow.cn/v1")
|
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.siliconflow.cn/v1")
|
||||||
LLM_MODEL = os.environ.get("LLM_MODEL", "")
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import logging
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -15,6 +16,9 @@ MAX_PREVIEW_CHARS = 3000
|
|||||||
MAX_ROWS_PER_SHEET = 20
|
MAX_ROWS_PER_SHEET = 20
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.attachment_reader")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AttachmentReadResult:
|
class AttachmentReadResult:
|
||||||
status: str
|
status: str
|
||||||
@@ -32,10 +36,29 @@ class AttachmentReadResult:
|
|||||||
def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult:
|
def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult:
|
||||||
file_path = _attachment_absolute_path(attachment)
|
file_path = _attachment_absolute_path(attachment)
|
||||||
file_type = Path(attachment.original_name).suffix.lower().lstrip(".")
|
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():
|
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, "附件文件不存在。")
|
return _failed(attachment, file_type, "附件文件不存在。")
|
||||||
if file_type not in SUPPORTED_EXTENSIONS:
|
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")
|
return _failed(attachment, file_type, f"暂不支持解析 .{file_type or 'unknown'} 文件。", "unsupported")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -52,9 +75,21 @@ def read_attachment_details(attachment: FileAttachment) -> AttachmentReadResult:
|
|||||||
else:
|
else:
|
||||||
sections = _read_text(file_path)
|
sections = _read_text(file_path)
|
||||||
except Exception as exc:
|
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))
|
return _failed(attachment, file_type, str(exc))
|
||||||
|
|
||||||
preview = _build_preview(sections)
|
preview = _build_preview(sections)
|
||||||
|
logger.info(
|
||||||
|
"Attachment read finished",
|
||||||
|
extra={
|
||||||
|
"attachment_id": attachment.pk,
|
||||||
|
"section_count": len(sections),
|
||||||
|
"preview_length": len(preview),
|
||||||
|
},
|
||||||
|
)
|
||||||
return AttachmentReadResult(
|
return AttachmentReadResult(
|
||||||
status="success",
|
status="success",
|
||||||
filename=attachment.original_name,
|
filename=attachment.original_name,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -8,6 +9,9 @@ from openpyxl import Workbook
|
|||||||
from review_agent.models import ExportedSummaryFile, FileSummaryBatch
|
from review_agent.models import ExportedSummaryFile, FileSummaryBatch
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.export_excel")
|
||||||
|
|
||||||
|
|
||||||
def _exports_dir(batch: FileSummaryBatch) -> Path:
|
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
|
root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "file_summary" / batch.batch_no
|
||||||
export_dir = root / "exports"
|
export_dir = root / "exports"
|
||||||
@@ -16,6 +20,7 @@ def _exports_dir(batch: FileSummaryBatch) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile:
|
def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile:
|
||||||
|
logger.info("Excel export generation started", extra={"batch_id": batch.pk})
|
||||||
workbook = Workbook()
|
workbook = Workbook()
|
||||||
summary = workbook.active
|
summary = workbook.active
|
||||||
summary.title = "汇总信息"
|
summary.title = "汇总信息"
|
||||||
@@ -47,9 +52,14 @@ def generate_excel_export(batch: FileSummaryBatch) -> ExportedSummaryFile:
|
|||||||
|
|
||||||
path = _exports_dir(batch) / f"{batch.batch_no}-summary.xlsx"
|
path = _exports_dir(batch) / f"{batch.batch_no}-summary.xlsx"
|
||||||
workbook.save(path)
|
workbook.save(path)
|
||||||
return ExportedSummaryFile.objects.create(
|
exported = ExportedSummaryFile.objects.create(
|
||||||
batch=batch,
|
batch=batch,
|
||||||
export_type=ExportedSummaryFile.ExportType.EXCEL,
|
export_type=ExportedSummaryFile.ExportType.EXCEL,
|
||||||
file_name=path.name,
|
file_name=path.name,
|
||||||
storage_path=str(path),
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -7,6 +8,9 @@ from django.conf import settings
|
|||||||
from review_agent.models import ExportedSummaryFile, FileSummaryBatch
|
from review_agent.models import ExportedSummaryFile, FileSummaryBatch
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.report")
|
||||||
|
|
||||||
|
|
||||||
def _exports_dir(batch: FileSummaryBatch) -> Path:
|
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
|
root = Path(batch.work_dir) if batch.work_dir else Path(settings.MEDIA_ROOT) / "file_summary" / batch.batch_no
|
||||||
export_dir = root / "exports"
|
export_dir = root / "exports"
|
||||||
@@ -55,6 +59,7 @@ def build_markdown_report(batch: FileSummaryBatch) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFile, 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)
|
content = build_markdown_report(batch)
|
||||||
path = _exports_dir(batch) / f"{batch.batch_no}-summary.md"
|
path = _exports_dir(batch) / f"{batch.batch_no}-summary.md"
|
||||||
path.write_text(content, encoding="utf-8")
|
path.write_text(content, encoding="utf-8")
|
||||||
@@ -64,4 +69,8 @@ def generate_markdown_report(batch: FileSummaryBatch) -> tuple[ExportedSummaryFi
|
|||||||
file_name=path.name,
|
file_name=path.name,
|
||||||
storage_path=str(path),
|
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)
|
return exported, build_summary_table(batch)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from review_agent.models import FileSummaryBatchAttachment
|
from review_agent.models import FileSummaryBatchAttachment
|
||||||
@@ -9,6 +10,9 @@ from ..services.archive import ARCHIVE_EXTENSIONS, extract_archive
|
|||||||
from .base import BaseSkill, SkillResult, WorkflowContext
|
from .base import BaseSkill, SkillResult, WorkflowContext
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.skills.archive_extract")
|
||||||
|
|
||||||
|
|
||||||
class ArchiveExtractSkill(BaseSkill):
|
class ArchiveExtractSkill(BaseSkill):
|
||||||
name = "archive_extract"
|
name = "archive_extract"
|
||||||
|
|
||||||
@@ -16,11 +20,27 @@ class ArchiveExtractSkill(BaseSkill):
|
|||||||
extracted_count = 0
|
extracted_count = 0
|
||||||
target_dir = Path(context.batch.work_dir or "")
|
target_dir = Path(context.batch.work_dir or "")
|
||||||
if not target_dir:
|
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})
|
return SkillResult(success=True, data={"extracted_count": 0})
|
||||||
|
|
||||||
for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch):
|
for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch):
|
||||||
path = resolve_storage_path(binding.attachment.storage_path)
|
path = resolve_storage_path(binding.attachment.storage_path)
|
||||||
if path.suffix.lower().lstrip(".") not in ARCHIVE_EXTENSIONS:
|
if path.suffix.lower().lstrip(".") not in ARCHIVE_EXTENSIONS:
|
||||||
continue
|
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))
|
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})
|
return SkillResult(success=True, data={"extracted_count": extracted_count})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from review_agent.models import FileAttachment
|
from review_agent.models import FileAttachment
|
||||||
@@ -8,6 +9,9 @@ from ..services.attachment_reader import read_attachment_details
|
|||||||
from .base import BaseSkill, SkillResult, WorkflowContext
|
from .base import BaseSkill, SkillResult, WorkflowContext
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.skills.attachment_reader")
|
||||||
|
|
||||||
|
|
||||||
class AttachmentReaderSkill(BaseSkill):
|
class AttachmentReaderSkill(BaseSkill):
|
||||||
name = "attachment_reader"
|
name = "attachment_reader"
|
||||||
|
|
||||||
@@ -19,11 +23,28 @@ class AttachmentReaderSkill(BaseSkill):
|
|||||||
return self.run_for_attachments(attachments)
|
return self.run_for_attachments(attachments)
|
||||||
|
|
||||||
def run_for_attachments(self, attachments: Iterable[FileAttachment]) -> SkillResult:
|
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:
|
if not results:
|
||||||
|
logger.warning("Attachment reader skill found no attachments")
|
||||||
return SkillResult(success=False, message="当前对话没有可读取的附件。")
|
return SkillResult(success=False, message="当前对话没有可读取的附件。")
|
||||||
|
|
||||||
has_success = any(item["status"] == "success" for item in results)
|
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(
|
return SkillResult(
|
||||||
success=has_success,
|
success=has_success,
|
||||||
data={"attachments": results},
|
data={"attachments": results},
|
||||||
|
|||||||
@@ -1,25 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from review_agent.models import FileSummaryItem
|
from review_agent.models import FileSummaryItem
|
||||||
|
|
||||||
from ..services.page_count import SUPPORTED_EXTENSIONS, count_document_pages
|
from ..services.page_count import SUPPORTED_EXTENSIONS, count_document_pages
|
||||||
from .base import BaseSkill, SkillResult, WorkflowContext
|
from .base import BaseSkill, SkillResult, WorkflowContext
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.skills.document_page_count")
|
||||||
|
|
||||||
|
|
||||||
class DocumentPageCountSkill(BaseSkill):
|
class DocumentPageCountSkill(BaseSkill):
|
||||||
name = "document_page_count"
|
name = "document_page_count"
|
||||||
|
|
||||||
def run(self, context: WorkflowContext) -> SkillResult:
|
def run(self, context: WorkflowContext) -> SkillResult:
|
||||||
success_files = failed_files = unsupported_files = uncertain_files = total_pages = 0
|
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"):
|
for item in context.batch.items.order_by("file_index"):
|
||||||
if item.file_type not in SUPPORTED_EXTENSIONS:
|
if item.file_type not in SUPPORTED_EXTENSIONS:
|
||||||
item.statistics_status = FileSummaryItem.StatisticsStatus.UNSUPPORTED
|
item.statistics_status = FileSummaryItem.StatisticsStatus.UNSUPPORTED
|
||||||
unsupported_files += 1
|
unsupported_files += 1
|
||||||
item.save(update_fields=["statistics_status", "updated_at"])
|
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
|
continue
|
||||||
|
|
||||||
result = None
|
result = None
|
||||||
for attempt in range(1, 4):
|
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)
|
result = count_document_pages(item.storage_path)
|
||||||
item.retry_count = attempt - 1
|
item.retry_count = attempt - 1
|
||||||
if result.status != "failed":
|
if result.status != "failed":
|
||||||
@@ -46,6 +70,15 @@ class DocumentPageCountSkill(BaseSkill):
|
|||||||
unsupported_files += 1
|
unsupported_files += 1
|
||||||
else:
|
else:
|
||||||
failed_files += 1
|
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.success_files = success_files
|
||||||
context.batch.failed_files = failed_files
|
context.batch.failed_files = failed_files
|
||||||
@@ -61,4 +94,15 @@ class DocumentPageCountSkill(BaseSkill):
|
|||||||
"total_pages",
|
"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)
|
return SkillResult(success=True)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from review_agent.models import FileSummaryBatchAttachment
|
from review_agent.models import FileSummaryBatchAttachment
|
||||||
@@ -9,6 +10,9 @@ from ..services.inventory import scan_files_to_items
|
|||||||
from .base import BaseSkill, SkillResult, WorkflowContext
|
from .base import BaseSkill, SkillResult, WorkflowContext
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.skills.file_inventory")
|
||||||
|
|
||||||
|
|
||||||
class FileInventorySkill(BaseSkill):
|
class FileInventorySkill(BaseSkill):
|
||||||
name = "file_inventory"
|
name = "file_inventory"
|
||||||
|
|
||||||
@@ -17,5 +21,17 @@ class FileInventorySkill(BaseSkill):
|
|||||||
resolve_storage_path(binding.attachment.storage_path)
|
resolve_storage_path(binding.attachment.storage_path)
|
||||||
for binding in FileSummaryBatchAttachment.objects.filter(batch=context.batch)
|
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)
|
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)})
|
return SkillResult(success=True, data={"total_files": len(items)})
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from ..services.product_detect import detect_product_name
|
from ..services.product_detect import detect_product_name
|
||||||
from .base import BaseSkill, SkillResult, WorkflowContext
|
from .base import BaseSkill, SkillResult, WorkflowContext
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.skills.product_detect")
|
||||||
|
|
||||||
|
|
||||||
class ProductDetectSkill(BaseSkill):
|
class ProductDetectSkill(BaseSkill):
|
||||||
name = "product_detect"
|
name = "product_detect"
|
||||||
|
|
||||||
def run(self, context: WorkflowContext) -> SkillResult:
|
def run(self, context: WorkflowContext) -> SkillResult:
|
||||||
|
logger.info("Product detect started", extra={"batch_id": context.batch.pk})
|
||||||
product_name = detect_product_name(context.batch)
|
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})
|
return SkillResult(success=True, data={"product_name": product_name})
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from .base import BaseSkill, SkillResult, WorkflowContext
|
from .base import BaseSkill, SkillResult, WorkflowContext
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.skills")
|
||||||
|
|
||||||
|
|
||||||
class SkillRegistry:
|
class SkillRegistry:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._skills: dict[str, BaseSkill] = {}
|
self._skills: dict[str, BaseSkill] = {}
|
||||||
@@ -11,6 +16,7 @@ class SkillRegistry:
|
|||||||
if not skill.name:
|
if not skill.name:
|
||||||
raise ValueError("Skill 必须声明 name。")
|
raise ValueError("Skill 必须声明 name。")
|
||||||
self._skills[skill.name] = skill
|
self._skills[skill.name] = skill
|
||||||
|
logger.info("Skill registered: %s", skill.name, extra={"skill_name": skill.name})
|
||||||
|
|
||||||
def get(self, name: str) -> BaseSkill:
|
def get(self, name: str) -> BaseSkill:
|
||||||
try:
|
try:
|
||||||
@@ -19,4 +25,20 @@ class SkillRegistry:
|
|||||||
raise KeyError(f"Skill 未注册:{name}") from exc
|
raise KeyError(f"Skill 未注册:{name}") from exc
|
||||||
|
|
||||||
def execute(self, name: str, context: WorkflowContext) -> SkillResult:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from review_agent.models import Message
|
from review_agent.models import Message
|
||||||
@@ -9,10 +11,14 @@ from ..services.report import generate_markdown_report
|
|||||||
from .base import BaseSkill, SkillResult, WorkflowContext
|
from .base import BaseSkill, SkillResult, WorkflowContext
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.skills.summary_report")
|
||||||
|
|
||||||
|
|
||||||
class SummaryReportSkill(BaseSkill):
|
class SummaryReportSkill(BaseSkill):
|
||||||
name = "summary_report"
|
name = "summary_report"
|
||||||
|
|
||||||
def run(self, context: WorkflowContext) -> SkillResult:
|
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)
|
markdown_export, summary_table = generate_markdown_report(context.batch)
|
||||||
excel_export = generate_excel_export(context.batch)
|
excel_export = generate_excel_export(context.batch)
|
||||||
markdown_url = reverse("file_summary_export_download", args=[markdown_export.pk])
|
markdown_url = reverse("file_summary_export_download", args=[markdown_export.pk])
|
||||||
@@ -27,6 +33,14 @@ class SummaryReportSkill(BaseSkill):
|
|||||||
role=Message.Role.ASSISTANT,
|
role=Message.Role.ASSISTANT,
|
||||||
content=content,
|
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(
|
return SkillResult(
|
||||||
success=True,
|
success=True,
|
||||||
data={"markdown_export_id": markdown_export.pk, "excel_export_id": excel_export.pk},
|
data={"markdown_export_id": markdown_export.pk, "excel_export_id": excel_export.pk},
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -12,6 +13,9 @@ from review_agent.models import Conversation, FileAttachment
|
|||||||
from .constants import ATTACHMENT_ROOT
|
from .constants import ATTACHMENT_ROOT
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.storage")
|
||||||
|
|
||||||
|
|
||||||
def _safe_original_name(name: str) -> str:
|
def _safe_original_name(name: str) -> str:
|
||||||
clean = get_valid_filename(Path(name).name)
|
clean = get_valid_filename(Path(name).name)
|
||||||
return clean or f"upload-{uuid4().hex}"
|
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."""
|
"""Stores an uploaded file and creates a versioned attachment record."""
|
||||||
|
|
||||||
original_name = _safe_original_name(uploaded_file.name)
|
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 = (
|
latest = (
|
||||||
FileAttachment.objects.filter(conversation=conversation, original_name=original_name)
|
FileAttachment.objects.filter(conversation=conversation, original_name=original_name)
|
||||||
.order_by("-version_no")
|
.order_by("-version_no")
|
||||||
@@ -63,7 +77,7 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file)
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
).update(is_active=False)
|
).update(is_active=False)
|
||||||
|
|
||||||
return FileAttachment.objects.create(
|
attachment = FileAttachment.objects.create(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
user=user,
|
user=user,
|
||||||
original_name=original_name,
|
original_name=original_name,
|
||||||
@@ -73,6 +87,16 @@ def save_uploaded_attachment(*, conversation: Conversation, user, uploaded_file)
|
|||||||
file_size=uploaded_file.size,
|
file_size=uploaded_file.size,
|
||||||
content_type=getattr(uploaded_file, "content_type", "") or "",
|
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]:
|
def serialize_attachment(attachment: FileAttachment) -> dict[str, object]:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.http import FileResponse, Http404, JsonResponse
|
from django.http import FileResponse, Http404, JsonResponse
|
||||||
@@ -11,6 +12,9 @@ from .events import serialize_event
|
|||||||
from .storage import save_uploaded_attachment, serialize_attachment
|
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:
|
def _conversation_for_user(user, conversation_id: int) -> Conversation:
|
||||||
conversation = Conversation.objects.filter(pk=conversation_id, user=user).first()
|
conversation = Conversation.objects.filter(pk=conversation_id, user=user).first()
|
||||||
if not conversation:
|
if not conversation:
|
||||||
@@ -27,6 +31,15 @@ def attachments(request, conversation_id: int):
|
|||||||
files = request.FILES.getlist("files")
|
files = request.FILES.getlist("files")
|
||||||
if not files:
|
if not files:
|
||||||
return JsonResponse({"error": "请选择至少一个文件。"}, status=400)
|
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 = [
|
saved = [
|
||||||
save_uploaded_attachment(
|
save_uploaded_attachment(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
@@ -35,12 +48,23 @@ def attachments(request, conversation_id: int):
|
|||||||
)
|
)
|
||||||
for uploaded_file in files
|
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]})
|
return JsonResponse({"attachments": [serialize_attachment(item) for item in saved]})
|
||||||
|
|
||||||
queryset = FileAttachment.objects.filter(conversation=conversation).order_by(
|
queryset = FileAttachment.objects.filter(conversation=conversation).order_by(
|
||||||
"original_name",
|
"original_name",
|
||||||
"-version_no",
|
"-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]})
|
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.upload_status = FileAttachment.UploadStatus.DELETED
|
||||||
attachment.is_active = False
|
attachment.is_active = False
|
||||||
attachment.save(update_fields=["upload_status", "is_active"])
|
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)})
|
return JsonResponse({"ok": True, "attachment": serialize_attachment(attachment)})
|
||||||
|
|
||||||
|
|
||||||
@@ -120,12 +148,25 @@ def export_download(request, export_id: int):
|
|||||||
raise Http404("导出文件不存在。")
|
raise Http404("导出文件不存在。")
|
||||||
path = Path(exported.storage_path)
|
path = Path(exported.storage_path)
|
||||||
if not path.exists():
|
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)
|
return JsonResponse({"error": "文件不存在。"}, status=404)
|
||||||
content_type = (
|
content_type = (
|
||||||
"text/markdown; charset=utf-8"
|
"text/markdown; charset=utf-8"
|
||||||
if exported.export_type == ExportedSummaryFile.ExportType.MARKDOWN
|
if exported.export_type == ExportedSummaryFile.ExportType.MARKDOWN
|
||||||
else "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
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(
|
return FileResponse(
|
||||||
path.open("rb"),
|
path.open("rb"),
|
||||||
as_attachment=True,
|
as_attachment=True,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ NODE_DEFINITIONS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("review_agent.file_summary.workflow")
|
||||||
|
|
||||||
|
|
||||||
def default_skill_registry() -> SkillRegistry:
|
def default_skill_registry() -> SkillRegistry:
|
||||||
registry = SkillRegistry()
|
registry = SkillRegistry()
|
||||||
registry.register(ArchiveExtractSkill())
|
registry.register(ArchiveExtractSkill())
|
||||||
@@ -65,6 +69,14 @@ def create_file_summary_batch(
|
|||||||
)
|
)
|
||||||
if not active_attachments:
|
if not active_attachments:
|
||||||
raise ValueError("当前对话没有可用附件。")
|
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(
|
batch = FileSummaryBatch.objects.create(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
@@ -82,6 +94,10 @@ def create_file_summary_batch(
|
|||||||
WorkflowNodeRun.objects.create(batch=batch, node_code=code, node_name=name)
|
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})
|
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
|
return batch
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +107,7 @@ class WorkflowExecutor:
|
|||||||
self.registry = registry or default_skill_registry()
|
self.registry = registry or default_skill_registry()
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
logger.info("Workflow run started", extra={"batch_id": self.batch.pk})
|
||||||
self.batch.status = FileSummaryBatch.Status.RUNNING
|
self.batch.status = FileSummaryBatch.Status.RUNNING
|
||||||
self.batch.started_at = timezone.now()
|
self.batch.started_at = timezone.now()
|
||||||
self.batch.save(update_fields=["status", "started_at"])
|
self.batch.save(update_fields=["status", "started_at"])
|
||||||
@@ -100,6 +117,10 @@ class WorkflowExecutor:
|
|||||||
for node in self.batch.node_runs.order_by("id"):
|
for node in self.batch.node_runs.order_by("id"):
|
||||||
self._run_node(node)
|
self._run_node(node)
|
||||||
except Exception as exc:
|
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.status = FileSummaryBatch.Status.FAILED
|
||||||
self.batch.error_message = str(exc)
|
self.batch.error_message = str(exc)
|
||||||
self.batch.finished_at = timezone.now()
|
self.batch.finished_at = timezone.now()
|
||||||
@@ -111,8 +132,17 @@ class WorkflowExecutor:
|
|||||||
self.batch.finished_at = timezone.now()
|
self.batch.finished_at = timezone.now()
|
||||||
self.batch.save(update_fields=["status", "finished_at"])
|
self.batch.save(update_fields=["status", "finished_at"])
|
||||||
record_event(self.batch, "workflow_completed", {"batch_id": self.batch.pk})
|
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:
|
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()
|
now = timezone.now()
|
||||||
node.status = WorkflowNodeRun.Status.RUNNING
|
node.status = WorkflowNodeRun.Status.RUNNING
|
||||||
node.progress = 10
|
node.progress = 10
|
||||||
@@ -132,6 +162,15 @@ class WorkflowExecutor:
|
|||||||
if skill_name:
|
if skill_name:
|
||||||
result = self.registry.execute(skill_name, WorkflowContext(batch=self.batch))
|
result = self.registry.execute(skill_name, WorkflowContext(batch=self.batch))
|
||||||
if not result.success:
|
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}执行失败")
|
raise RuntimeError(result.message or f"{node.node_name}执行失败")
|
||||||
|
|
||||||
node.status = WorkflowNodeRun.Status.SUCCESS
|
node.status = WorkflowNodeRun.Status.SUCCESS
|
||||||
@@ -144,11 +183,17 @@ class WorkflowExecutor:
|
|||||||
"node_progress",
|
"node_progress",
|
||||||
{"node_code": node.node_code, "status": node.status, "progress": 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:
|
def start_file_summary_workflow(batch: FileSummaryBatch, *, async_run: bool = True) -> None:
|
||||||
executor = WorkflowExecutor(batch)
|
executor = WorkflowExecutor(batch)
|
||||||
if not async_run:
|
if not async_run:
|
||||||
|
logger.info("Workflow starting synchronously", extra={"batch_id": batch.pk})
|
||||||
executor.run()
|
executor.run()
|
||||||
return
|
return
|
||||||
|
logger.info("Workflow starting asynchronously", extra={"batch_id": batch.pk})
|
||||||
Thread(target=executor.run, daemon=True).start()
|
Thread(target=executor.run, daemon=True).start()
|
||||||
|
|||||||
@@ -14,11 +14,38 @@ ATTACHMENT_READER_KEYWORDS = (
|
|||||||
"查看附件",
|
"查看附件",
|
||||||
"附件详情",
|
"附件详情",
|
||||||
"文件详情",
|
"文件详情",
|
||||||
|
"文件内容",
|
||||||
|
"附件内容",
|
||||||
|
"简历文件",
|
||||||
|
"提供的文件",
|
||||||
|
"提供的简历",
|
||||||
|
"上传的文件",
|
||||||
|
"上传文件",
|
||||||
|
"这个文件",
|
||||||
|
"该文件",
|
||||||
"总结附件",
|
"总结附件",
|
||||||
"总结文件",
|
"总结文件",
|
||||||
"分析这个文件",
|
"分析这个文件",
|
||||||
"阅读这个文件",
|
"阅读这个文件",
|
||||||
)
|
)
|
||||||
|
ATTACHMENT_REFERENCE_KEYWORDS = ("附件", "文件", "简历", "上传")
|
||||||
|
ATTACHMENT_READ_INTENT_KEYWORDS = (
|
||||||
|
"阅读",
|
||||||
|
"读取",
|
||||||
|
"读",
|
||||||
|
"解析",
|
||||||
|
"分析",
|
||||||
|
"查看",
|
||||||
|
"提取",
|
||||||
|
"整理",
|
||||||
|
"总结",
|
||||||
|
"介绍",
|
||||||
|
"项目经历",
|
||||||
|
"工作经历",
|
||||||
|
"经历",
|
||||||
|
"信息",
|
||||||
|
"内容",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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:
|
def evaluate_attachment_reader_trigger(conversation: Conversation, content: str) -> TriggerResult:
|
||||||
text = (content or "").strip()
|
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")
|
return TriggerResult(should_start=False, reason="not_matched")
|
||||||
|
|
||||||
has_attachment = FileAttachment.objects.filter(
|
has_attachment = FileAttachment.objects.filter(
|
||||||
|
|||||||
@@ -53,6 +53,47 @@ def generate_reply(conversation, user_message: str) -> str:
|
|||||||
raise LLMRequestError("模型接口返回格式不符合预期。") from exc
|
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):
|
def stream_reply(conversation, user_message: str):
|
||||||
"""Streams incremental assistant text from the SiliconFlow chat endpoint."""
|
"""Streams incremental assistant text from the SiliconFlow chat endpoint."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.db.models import Q, QuerySet
|
from django.db.models import Q, QuerySet
|
||||||
from django.conf import settings
|
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.skills.attachment_reader import AttachmentReaderSkill
|
||||||
from .file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
|
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 .llm import LLMConfigurationError, LLMRequestError, generate_reply, stream_reply
|
||||||
from .models import Conversation, FileAttachment, Message
|
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]:
|
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,
|
role=Message.Role.USER,
|
||||||
content=content.strip(),
|
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:
|
if conversation.messages.filter(role=Message.Role.USER).count() == 1:
|
||||||
conversation.title = build_conversation_title(content)
|
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:
|
def append_assistant_message(conversation: Conversation, content: str) -> Message:
|
||||||
"""Appends the deterministic assistant reply."""
|
"""Appends the deterministic assistant reply."""
|
||||||
|
|
||||||
return Message.objects.create(
|
message = Message.objects.create(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
role=Message.Role.ASSISTANT,
|
role=Message.Role.ASSISTANT,
|
||||||
content=content,
|
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]:
|
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)
|
user_message = append_user_message(conversation, content)
|
||||||
assistant_parts: list[str] = []
|
assistant_parts: list[str] = []
|
||||||
trigger = evaluate_file_summary_trigger(conversation, content)
|
route = route_message_intent(conversation, content)
|
||||||
attachment_reader_trigger = evaluate_attachment_reader_trigger(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(
|
yield sse_event(
|
||||||
"meta",
|
"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 = "请先在当前对话右侧上传需要汇总的文件或压缩包,然后再发送自动汇总指令。"
|
reply_content = "请先在当前对话右侧上传需要汇总的文件或压缩包,然后再发送自动汇总指令。"
|
||||||
assistant_message = append_assistant_message(conversation, reply_content)
|
assistant_message = append_assistant_message(conversation, reply_content)
|
||||||
yield sse_event("chunk", {"delta": reply_content})
|
yield sse_event("chunk", {"delta": reply_content})
|
||||||
@@ -122,7 +150,7 @@ def stream_message(conversation: Conversation, content: str):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if attachment_reader_trigger.reason == "missing_attachment":
|
if route.uses_attachment_reader and not _has_active_attachments(conversation):
|
||||||
reply_content = "请先在当前对话右侧上传需要阅读的附件,然后再发送解析或阅读附件指令。"
|
reply_content = "请先在当前对话右侧上传需要阅读的附件,然后再发送解析或阅读附件指令。"
|
||||||
assistant_message = append_assistant_message(conversation, reply_content)
|
assistant_message = append_assistant_message(conversation, reply_content)
|
||||||
yield sse_event("chunk", {"delta": reply_content})
|
yield sse_event("chunk", {"delta": reply_content})
|
||||||
@@ -136,8 +164,16 @@ def stream_message(conversation: Conversation, content: str):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if attachment_reader_trigger.should_start:
|
if route.uses_attachment_reader:
|
||||||
attachments = _select_attachments_for_reader(conversation, content)
|
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)
|
result = AttachmentReaderSkill().run_for_attachments(attachments)
|
||||||
reply_content = _format_attachment_reader_reply(result.data.get("attachments", []), result.message)
|
reply_content = _format_attachment_reader_reply(result.data.get("attachments", []), result.message)
|
||||||
assistant_message = append_assistant_message(conversation, reply_content)
|
assistant_message = append_assistant_message(conversation, reply_content)
|
||||||
@@ -152,7 +188,7 @@ def stream_message(conversation: Conversation, content: str):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if trigger.should_start:
|
if route.starts_file_summary:
|
||||||
batch = create_file_summary_batch(
|
batch = create_file_summary_batch(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
user=conversation.user,
|
user=conversation.user,
|
||||||
@@ -190,6 +226,18 @@ def stream_message(conversation: Conversation, content: str):
|
|||||||
except (LLMConfigurationError, LLMRequestError) as exc:
|
except (LLMConfigurationError, LLMRequestError) as exc:
|
||||||
fallback = f"模型调用失败:{exc}"
|
fallback = f"模型调用失败:{exc}"
|
||||||
assistant_parts = [fallback]
|
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})
|
yield sse_event("error", {"message": fallback})
|
||||||
|
|
||||||
assistant_message = append_assistant_message(conversation, "".join(assistant_parts).strip())
|
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
|
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:
|
def _format_attachment_reader_reply(attachments: list[dict[str, object]], message: str) -> str:
|
||||||
if not attachments:
|
if not attachments:
|
||||||
return message or "当前对话没有可读取的附件。"
|
return message or "当前对话没有可读取的附件。"
|
||||||
|
|||||||
189
review_agent/skill_router.py
Normal file
189
review_agent/skill_router.py
Normal file
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
import logging
|
||||||
|
|
||||||
from review_agent.file_summary.skills.base import BaseSkill, SkillResult, WorkflowContext
|
from review_agent.file_summary.skills.base import BaseSkill, SkillResult, WorkflowContext
|
||||||
from review_agent.file_summary.skills.registry import SkillRegistry
|
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.success is True
|
||||||
assert result.data == {"batch_id": batch.id}
|
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)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import pytest
|
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
|
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, "你好,帮我解释法规")
|
normal = evaluate_file_summary_trigger(conversation, "你好,帮我解释法规")
|
||||||
assert normal.should_start is False
|
assert normal.should_start is False
|
||||||
assert normal.reason == "not_matched"
|
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"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from review_agent.file_summary.workflow import create_file_summary_batch, start_file_summary_workflow
|
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 (
|
from review_agent.models import (
|
||||||
Conversation,
|
Conversation,
|
||||||
FileAttachment,
|
FileAttachment,
|
||||||
@@ -102,6 +103,21 @@ def test_stream_message_uses_normal_llm_path_when_not_triggered(monkeypatch, dja
|
|||||||
assert "workflow_started" not in joined
|
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):
|
def test_stream_message_reads_active_attachment_when_requested(settings, tmp_path, django_user_model):
|
||||||
settings.MEDIA_ROOT = tmp_path
|
settings.MEDIA_ROOT = tmp_path
|
||||||
user = django_user_model.objects.create_user(username="owner", password="pass")
|
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 "detail.txt" in joined
|
||||||
assert "RA-2026" in joined
|
assert "RA-2026" in joined
|
||||||
assert "workflow_started" not 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user