Files

466 lines
16 KiB
Python

from __future__ import annotations
import csv
import json
import shutil
import uuid
import zipfile
from pathlib import Path
from urllib.parse import unquote, urlparse
from urllib.request import url2pathname
import httpx
from fastapi import UploadFile
from .config import AppConfig, ROOT_DIR
from .deepseek import DeepSeekMapper, persist_high_confidence_mappings
from .excel_writer import XlsTemplateWriter
from .models import JobResult, WeldRecord
from .pcf_parser import PcfParser
JOBS_DIR = ROOT_DIR / "outputs" / "jobs"
UPLOADS_DIR = ROOT_DIR / "outputs" / "uploads"
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
class JobStore:
def __init__(self) -> None:
self.results: dict[str, JobResult] = {}
def set(self, result: JobResult) -> None:
self.results[result.job_id] = result
def get(self, job_id: str) -> JobResult | None:
return self.results.get(job_id)
job_store = JobStore()
def safe_filename(name: str) -> str:
keep = []
for ch in Path(name).name:
if ch.isalnum() or ch in ".-_() ":
keep.append(ch)
else:
keep.append("_")
return "".join(keep) or "attachment.pcf"
async def process_uploads(files: list[UploadFile]) -> JobResult:
job_id = uuid.uuid4().hex[:12]
upload_dir = UPLOADS_DIR / job_id
upload_dir.mkdir(parents=True, exist_ok=True)
saved_files: list[Path] = []
messages: list[str] = []
for upload in files:
filename = safe_filename(upload.filename or "upload.pcf")
target = upload_dir / filename
if target.suffix.lower() != ".pcf":
messages.append(f"拒绝非 PCF 文件:{filename}")
continue
with target.open("wb") as f:
shutil.copyfileobj(upload.file, f)
saved_files.append(target)
return await process_pcf_paths(job_id=job_id, pcf_files=saved_files, initial_messages=messages)
async def analyze_attachment_urls(urls: list[str]) -> dict:
job_id = uuid.uuid4().hex[:12]
download_dir = UPLOADS_DIR / job_id
download_dir.mkdir(parents=True, exist_ok=True)
pcf_files, messages = await download_attachment_urls(urls, download_dir)
analysis = await analyze_pcf_paths(pcf_files=pcf_files, initial_messages=messages)
return analysis_to_json_payload(job_id=job_id, input_files=len(pcf_files), analysis=analysis)
async def process_pcf_paths(
job_id: str,
pcf_files: list[Path],
initial_messages: list[str] | None = None,
) -> JobResult:
job_dir = JOBS_DIR / job_id
job_dir.mkdir(parents=True, exist_ok=True)
analysis = await analyze_pcf_paths(pcf_files=pcf_files, initial_messages=initial_messages)
config = analysis["config"]
records = analysis["records"]
candidates = analysis["candidates"]
decisions = analysis["decisions"]
messages = analysis["messages"]
import_headers = analysis["import_headers"]
import_columns = analysis["import_columns"]
import_rows = analysis["import_rows"]
issue_rows = analysis["issue_rows"]
mapper_enabled = analysis["llm_enabled"]
output_xls = job_dir / "焊接数据导入结果.xls"
issues_csv = job_dir / "问题清单.csv"
report_json = job_dir / "解析报告.json"
writer = XlsTemplateWriter(config)
writer.write(records, output_xls)
write_issues_csv(issue_rows, issues_csv)
write_report_json(
records=records,
candidates=candidates,
decisions=decisions,
accepted_mappings=analysis["accepted_mappings"],
messages=messages,
report_path=report_json,
llm_enabled=mapper_enabled,
)
warning_count = analysis["warning_count"]
error_count = analysis["error_count"]
result = JobResult(
job_id=job_id,
input_files=len(pcf_files),
weld_count=len(records),
warning_count=warning_count,
error_count=error_count,
output_xls=output_xls,
issues_csv=issues_csv,
report_json=report_json,
messages=messages,
llm_enabled=mapper_enabled,
llm_mappings=decisions,
import_headers=import_headers,
import_columns=import_columns,
import_rows=import_rows,
issue_rows=issue_rows,
)
job_store.set(result)
return result
async def analyze_pcf_paths(
pcf_files: list[Path],
initial_messages: list[str] | None = None,
) -> dict:
messages = list(initial_messages or [])
config = AppConfig.load()
parser = PcfParser(config)
records, candidates, parse_messages = parser.parse_files(pcf_files)
messages.extend(parse_messages)
mapper = DeepSeekMapper(config)
decisions = []
accepted_mappings = {}
if mapper.enabled and candidates:
try:
decisions = await mapper.map_unknown_fields(candidates)
accepted_mappings = persist_high_confidence_mappings(config, decisions)
if accepted_mappings:
config = AppConfig.load()
parser = PcfParser(config)
records, _, parse_messages = parser.parse_files(pcf_files, accepted_mappings)
messages.extend(parse_messages)
except Exception as exc:
messages.append(f"DeepSeek 字段映射失败,已降级为规则解析:{exc}")
elif candidates:
messages.append("未在 config/mapping.yaml 配置 deepseek.api_key,已跳过非标准字段智能映射")
writer = XlsTemplateWriter(config)
import_headers, import_columns, import_rows = writer.build_import_rows(records)
issue_rows = build_issue_rows(records, candidates, decisions, config.confidence_threshold)
warning_count = sum(len(r.issues) for r in records) + len(messages)
error_count = 0 if records else 1
if not records:
messages.append("未解析到任何焊口记录")
return {
"config": config,
"records": records,
"candidates": candidates,
"decisions": decisions,
"accepted_mappings": accepted_mappings,
"messages": messages,
"llm_enabled": mapper.enabled,
"import_headers": import_headers,
"import_columns": import_columns,
"import_rows": import_rows,
"issue_rows": issue_rows,
"warning_count": warning_count,
"error_count": error_count,
}
async def download_attachment_urls(urls: list[str], target_dir: Path) -> tuple[list[Path], list[str]]:
pcf_files: list[Path] = []
messages: list[str] = []
async with httpx.AsyncClient(follow_redirects=True, timeout=60) as client:
for index, url in enumerate(urls, start=1):
parsed = urlparse(url)
if is_local_attachment(url, parsed):
local_files, local_messages = collect_local_attachment(url, target_dir, index)
pcf_files.extend(local_files)
messages.extend(local_messages)
continue
if parsed.scheme not in {"http", "https"}:
messages.append(f"附件引用协议不支持:{url}")
continue
downloaded, download_messages = await download_remote_attachment(client, url, target_dir, index)
pcf_files.extend(downloaded)
messages.extend(download_messages)
return pcf_files, messages
def is_local_attachment(raw: str, parsed) -> bool:
if parsed.scheme == "file":
return True
if parsed.scheme in {"http", "https"}:
return False
if len(raw) >= 3 and raw[1] == ":" and raw[2] in {"\\", "/"}:
return True
if raw.startswith("\\\\"):
return True
return parsed.scheme == ""
def collect_local_attachment(raw: str, target_dir: Path, index: int) -> tuple[list[Path], list[str]]:
messages: list[str] = []
pcf_files: list[Path] = []
source = local_path_from_reference(raw)
if not source.exists() or not source.is_file():
messages.append(f"本地附件不存在或不是文件:{raw}")
return pcf_files, messages
if source.stat().st_size > MAX_ATTACHMENT_BYTES:
messages.append(f"本地附件超过大小限制:{source}")
return pcf_files, messages
target = target_dir / safe_filename(source.name or f"attachment_{index}.pcf")
if source.resolve() != target.resolve():
shutil.copyfile(source, target)
else:
target = source
if target.suffix.lower() == ".pcf":
pcf_files.append(target)
elif target.suffix.lower() == ".zip":
extracted, extract_messages = extract_pcf_zip(target, target_dir / f"zip_{index}")
pcf_files.extend(extracted)
messages.extend(extract_messages)
else:
messages.append(f"本地附件不是 PCF 或 ZIP,已跳过:{source.name}")
return pcf_files, messages
def local_path_from_reference(raw: str) -> Path:
parsed = urlparse(raw)
if parsed.scheme == "file":
if parsed.netloc and parsed.netloc not in {"localhost", "127.0.0.1"}:
return Path(f"//{parsed.netloc}{url2pathname(parsed.path)}")
return Path(url2pathname(unquote(parsed.path)))
return Path(raw)
async def download_remote_attachment(
client: httpx.AsyncClient,
url: str,
target_dir: Path,
index: int,
) -> tuple[list[Path], list[str]]:
pcf_files: list[Path] = []
messages: list[str] = []
try:
response = await client.get(url)
response.raise_for_status()
except Exception as exc:
messages.append(f"附件下载失败:{url},原因:{exc}")
return pcf_files, messages
content = response.content
if len(content) > MAX_ATTACHMENT_BYTES:
messages.append(f"附件超过大小限制:{url}")
return pcf_files, messages
filename = filename_from_response(url, response.headers, index)
saved = target_dir / filename
saved.write_bytes(content)
if saved.suffix.lower() == ".pcf":
pcf_files.append(saved)
elif saved.suffix.lower() == ".zip":
extracted, extract_messages = extract_pcf_zip(saved, target_dir / f"zip_{index}")
pcf_files.extend(extracted)
messages.extend(extract_messages)
else:
messages.append(f"附件不是 PCF 或 ZIP,已跳过:{filename}")
return pcf_files, messages
def filename_from_response(url: str, headers: httpx.Headers, index: int) -> str:
disposition = headers.get("content-disposition", "")
marker = "filename="
if marker in disposition:
raw = disposition.split(marker, 1)[1].strip().strip('"')
return safe_filename(unquote(raw))
parsed_name = Path(unquote(urlparse(url).path)).name
return safe_filename(parsed_name or f"attachment_{index}.pcf")
def extract_pcf_zip(zip_path: Path, target_dir: Path) -> tuple[list[Path], list[str]]:
target_dir.mkdir(parents=True, exist_ok=True)
pcf_files: list[Path] = []
messages: list[str] = []
try:
with zipfile.ZipFile(zip_path) as archive:
for member in archive.infolist():
if member.is_dir():
continue
name = safe_filename(Path(member.filename).name)
if not name.lower().endswith(".pcf"):
continue
target = target_dir / name
with archive.open(member) as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
pcf_files.append(target)
except zipfile.BadZipFile:
messages.append(f"ZIP 附件无法解压:{zip_path.name}")
if not pcf_files:
messages.append(f"ZIP 附件中未找到 PCF 文件:{zip_path.name}")
return pcf_files, messages
def analysis_to_json_payload(job_id: str, input_files: int, analysis: dict) -> dict:
records: list[WeldRecord] = analysis["records"]
return {
"job_id": job_id,
"input_files": input_files,
"weld_count": len(records),
"warning_count": analysis["warning_count"],
"error_count": analysis["error_count"],
"messages": analysis["messages"],
"llm_enabled": analysis["llm_enabled"],
"tables": {
"import_headers": analysis["import_headers"],
"import_columns": analysis["import_columns"],
"import_rows": analysis["import_rows"],
"issue_rows": analysis["issue_rows"],
},
"report": build_report_payload(
records=records,
candidates=analysis["candidates"],
decisions=analysis["decisions"],
accepted_mappings=analysis["accepted_mappings"],
messages=analysis["messages"],
llm_enabled=analysis["llm_enabled"],
),
}
def build_issue_rows(
records: list[WeldRecord],
candidates,
decisions,
threshold: float,
) -> list[dict[str, str]]:
rows: list[dict[str, str]] = []
for record in records:
for issue in record.issues:
rows.append(
{
"类型": "解析警告",
"文件": record.source_file,
"焊口代号": record.weld_no,
"字段": "",
"内容": issue,
"处理状态": "需复核",
}
)
high_conf = {d.source_field for d in decisions if d.confidence >= threshold}
for candidate in candidates:
rows.append(
{
"类型": "未知字段",
"文件": "",
"焊口代号": "",
"字段": candidate.source_field,
"内容": "; ".join(candidate.sample_values),
"处理状态": "已自动映射" if candidate.source_field in high_conf else "未映射/需复核",
}
)
return rows
def write_issues_csv(issue_rows: list[dict[str, str]], path: Path) -> None:
with path.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f)
headers = ["类型", "文件", "焊口代号", "字段", "内容", "处理状态"]
writer.writerow(headers)
for row in issue_rows:
writer.writerow([row.get(header, "") for header in headers])
def write_report_json(
records: list[WeldRecord],
candidates,
decisions,
accepted_mappings: dict[str, str],
messages: list[str],
report_path: Path,
llm_enabled: bool,
) -> None:
payload = build_report_payload(
records=records,
candidates=candidates,
decisions=decisions,
accepted_mappings=accepted_mappings,
messages=messages,
llm_enabled=llm_enabled,
)
with report_path.open("w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def build_report_payload(
records: list[WeldRecord],
candidates,
decisions,
accepted_mappings: dict[str, str],
messages: list[str],
llm_enabled: bool,
) -> dict:
return {
"summary": {
"weld_count": len(records),
"source_files": sorted({r.source_file for r in records}),
"llm_enabled": llm_enabled,
"unknown_field_count": len(candidates),
},
"messages": messages,
"accepted_mappings": accepted_mappings,
"llm_mappings": [
{
"source_field": d.source_field,
"target_field": d.target_field,
"confidence": d.confidence,
"reason": d.reason,
"sample_values": d.sample_values,
}
for d in decisions
],
"records_preview": [
{
"source_file": r.source_file,
"pipeline_reference": r.pipeline_reference,
"line_no": r.line_no,
"weld_no": r.weld_no,
"diameter": r.diameter,
"weld_area": r.weld_area,
"issues": r.issues,
}
for r in records[:20]
],
}