Commit 4ecb616b by ccran

feat: add diff;

parent d710d227
from __future__ import annotations
import difflib
from itertools import zip_longest
from pathlib import Path
from typing import Any, Dict, List
from uuid import uuid4
from core.config import FULL_TEXT_SEGMENT_ID, doc_support_formats
from core.tool import ToolBase, tool, tool_func
from utils.http_util import download_file, upload_file
from utils.spire_word_util import SpireWordDoc
@tool("document_diff_comment", "对比两份 Word 文档、添加双向差异批注并上传")
class DocumentDiffCommentTool(ToolBase):
def __init__(self, tmp_dir: Path) -> None:
self._tmp_dir = Path(tmp_dir)
self._tmp_dir.mkdir(parents=True, exist_ok=True)
@tool_func(
{
"type": "object",
"properties": {
"urls": {
"type": "array",
"items": {"type": "string"},
"minItems": 2,
"maxItems": 2,
}
},
"required": ["urls"],
}
)
def run(self, urls: List[str]) -> Dict[str, List[str]]:
normalized_urls = [str(url or "").strip() for url in urls or []]
if len(normalized_urls) != 2:
raise ValueError("Exactly two URLs are required")
if any(not url for url in normalized_urls):
raise ValueError("URL cannot be empty")
request_dir = self._tmp_dir / f"document-diff-{uuid4().hex}"
downloaded_paths = self._download_documents(normalized_urls, request_dir)
texts = self._read_document_texts(downloaded_paths)
comments_by_document = [
self._build_diff_comments(texts[0], texts[1], "本文件", "文件2"),
self._build_diff_comments(texts[1], texts[0], "本文件", "文件1"),
]
output_paths = self._write_commented_documents(
downloaded_paths,
comments_by_document,
)
return {"urls": self._upload_documents(output_paths)}
def _download_documents(self, urls: List[str], request_dir: Path) -> List[Path]:
downloaded_paths: List[Path] = []
for index, url in enumerate(urls, start=1):
download_dir = request_dir / f"source-{index}"
download_dir.mkdir(parents=True, exist_ok=True)
try:
downloaded_path = download_file(url, download_dir)
except Exception as exc:
raise RuntimeError(f"File {index} download failed: {exc}") from exc
if not downloaded_path:
raise RuntimeError(f"File {index} download failed")
downloaded_path = Path(downloaded_path)
if downloaded_path.suffix.lower() not in doc_support_formats:
raise ValueError(
f"File {index} is not a supported Word document; "
f"supported formats: {', '.join(doc_support_formats)}"
)
downloaded_paths.append(downloaded_path)
return downloaded_paths
def _read_document_texts(self, paths: List[Path]) -> List[str]:
documents: List[SpireWordDoc] = []
try:
for path in paths:
document = SpireWordDoc()
documents.append(document)
document.load(str(path))
return [document.get_all_text() for document in documents]
except Exception as exc:
raise ValueError(f"Word document loading failed: {exc}") from exc
finally:
for document in documents:
if getattr(document, "_doc", None):
document._doc.Close()
def _write_commented_documents(
self,
source_paths: List[Path],
comments_by_document: List[List[Dict[str, Any]]],
) -> List[Path]:
output_paths: List[Path] = []
for index, (source_path, comments) in enumerate(
zip(source_paths, comments_by_document),
start=1,
):
output_path = source_path.with_name(
f"{source_path.stem}_差异批注_{index}{source_path.suffix}"
)
try:
self._save_commented_document(source_path, output_path, comments)
except Exception as exc:
raise RuntimeError(
f"Adding comments to file {index} failed: {exc}"
) from exc
output_paths.append(output_path)
return output_paths
def _upload_documents(self, paths: List[Path]) -> List[str]:
uploaded_urls: List[str] = []
for index, path in enumerate(paths, start=1):
try:
uploaded_urls.append(upload_file(str(path)))
except Exception as exc:
raise RuntimeError(f"File {index} upload failed: {exc}") from exc
return uploaded_urls
@staticmethod
def _nearest_diff_anchor(lines: List[str], position: int) -> str:
for offset in range(len(lines) + 1):
before_idx = position - 1 - offset
if before_idx >= 0 and lines[before_idx]:
return lines[before_idx]
after_idx = position + offset
if after_idx < len(lines) and lines[after_idx]:
return lines[after_idx]
return ""
def _build_diff_comments(
self,
current_text: str,
other_text: str,
current_label: str,
other_label: str,
) -> List[Dict[str, Any]]:
current_lines = [line.strip() for line in current_text.splitlines()]
other_lines = [line.strip() for line in other_text.splitlines()]
comment_batch_id = uuid4().hex[:12]
matcher = difflib.SequenceMatcher(
None,
current_lines,
other_lines,
autojunk=False,
)
comments: List[Dict[str, Any]] = []
for opcode_idx, (
tag,
current_start,
current_end,
other_start,
other_end,
) in enumerate(matcher.get_opcodes()):
if tag == "equal":
continue
current_part = current_lines[current_start:current_end]
other_part = other_lines[other_start:other_end]
for row_idx, (current_line, other_line) in enumerate(
zip_longest(current_part, other_part, fillvalue="")
):
if not current_line and not other_line:
continue
if current_line:
anchor = current_line
if other_line:
suggest = (
f"与{other_label}内容不一致。\n"
f"{current_label}:{current_line}\n"
f"{other_label}:{other_line}"
)
else:
suggest = (
f"与{other_label}内容不一致:"
f"本文件包含“{current_line}”,{other_label}中缺失该内容。"
)
else:
anchor = self._nearest_diff_anchor(current_lines, current_start)
suggest = (
f"与{other_label}内容不一致:"
f"{other_label}包含“{other_line}”,本文件中缺失该内容。"
)
comments.append(
{
"id": f"diff-{comment_batch_id}-{opcode_idx}-{row_idx}",
"key_points": "文件差异",
"result": "不合格",
"suggest": suggest,
"original_text": anchor,
"chunk_id": FULL_TEXT_SEGMENT_ID,
}
)
return comments
@staticmethod
def _save_commented_document(
source_path: Path,
output_path: Path,
comments: List[Dict[str, Any]],
) -> None:
document = SpireWordDoc()
try:
document.load(str(source_path))
if comments:
original_comment_count = document._doc.Comments.Count
document.add_chunk_comment(FULL_TEXT_SEGMENT_ID, comments)
added_comment_count = (
document._doc.Comments.Count - original_comment_count
)
if added_comment_count != len(comments):
raise RuntimeError(
f"{len(comments)} comments expected, "
f"but only {added_comment_count} were added"
)
document.to_file(str(output_path))
finally:
if getattr(document, "_doc", None):
document._doc.Close()
......@@ -35,6 +35,7 @@ from core.tools.fact_merger import FactMergerTool
from core.tools.ruleset_router import RulesetRouterTool
from core.tools.party_role import PartyRoleTool
from core.tools.segment_llm import LLMTool
from core.tools.document_diff_comment import DocumentDiffCommentTool
from core.memory import Finding
from core.memory import FINDING_KEY_MERGE, FINDING_KEY_REFLECT, FINDING_KEY_REVIEW
......@@ -58,6 +59,7 @@ fact_merger_tool = FactMergerTool()
ruleset_router_tool = RulesetRouterTool()
party_role_tool = PartyRoleTool()
nanobot_llm_tool = LLMTool(llm_key="nanobot_llm")
document_diff_comment_tool = DocumentDiffCommentTool(TMP_DIR)
class TestModel(BaseModel):
......@@ -118,6 +120,36 @@ class DocumentParseResponse(BaseModel):
file_name: Optional[str] = None
class DocumentDiffCommentRequest(BaseModel):
urls: List[str] = Field(
...,
min_length=2,
max_length=2,
description="需要对比并批注的两个 Word 文件下载 URL",
)
class DocumentDiffCommentResponse(BaseModel):
urls: List[str] = Field(description="两份已添加差异批注的 Word 文件上传 URL")
@app.post(
"/documents/diff-comments",
response_model=DocumentDiffCommentResponse,
)
def diff_and_comment_documents(
payload: DocumentDiffCommentRequest,
) -> DocumentDiffCommentResponse:
try:
result = document_diff_comment_tool.run(payload.urls)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Document diff comment processing failed")
raise HTTPException(status_code=500, detail=str(exc)) from exc
return DocumentDiffCommentResponse(urls=result["urls"])
class RulesetRouteRequest(BaseModel):
question: str = Field(..., description="User question used to select ruleset_id")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment