2026-08-26 00:25:46 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 图纸管理黄金测试:清除单张/清空项目 + AI 精确解析(fail-closed)。
|
|
|
|
|
|
# ============================================================
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
|
|
from server.aps_domain.drawing_ai import analyze_drawing_ai
|
|
|
|
|
|
from server.gateway.app import create_app
|
|
|
|
|
|
from tests.auth_provider import install_test_auth
|
2026-09-08 00:07:26 +08:00
|
|
|
|
from tests.external_data import external_dir
|
2026-08-26 00:25:46 +08:00
|
|
|
|
|
2026-09-08 00:07:26 +08:00
|
|
|
|
_RUIYANG_DIR = external_dir("RUIYANG_DEMO_DIR", "ruiyang")
|
|
|
|
|
|
_DEFAULT_SAMPLE = _RUIYANG_DIR / "5060102101-001-e(1).dxf"
|
|
|
|
|
|
_SAMPLE_003 = _RUIYANG_DIR / "5060102101-003-e(1).dxf"
|
2026-08-26 00:25:46 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sample(path: Path) -> Path:
|
|
|
|
|
|
if not path.is_file():
|
|
|
|
|
|
pytest.skip(f"real DXF fixture not available: {path}")
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeProvider:
|
|
|
|
|
|
enabled = True
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, payload):
|
|
|
|
|
|
self.payload = payload
|
|
|
|
|
|
|
|
|
|
|
|
async def chat_json(self, system, user, timeout=20.0):
|
|
|
|
|
|
if isinstance(self.payload, Exception):
|
|
|
|
|
|
raise self.payload
|
|
|
|
|
|
return self.payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ai_analysis_uses_llm_json_when_provider_available():
|
|
|
|
|
|
parsed = {
|
|
|
|
|
|
"asset": {"sha256": "0" * 64, "filename": "AI-001-a.dxf"},
|
|
|
|
|
|
"drawing": {"drawingNumber": "AI-001", "revision": "a"},
|
|
|
|
|
|
"fieldCandidates": [{"field": "drawingNumber", "value": "AI-001", "confidence": 0.9}],
|
|
|
|
|
|
"texts": [{"text": "DREHEN Ø20 TOLERANZ ±0.01", "evidence": {"source": "drawingText"}}],
|
|
|
|
|
|
"itemReferences": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"drawingNumber": "AI-001", "revision": "a", "title": "Test Part",
|
|
|
|
|
|
"processSteps": [{"code": "TURNING", "name": "车削", "description": "DREHEN Ø20", "confidence": 0.9}],
|
|
|
|
|
|
"details": {"tolerance": "±0.01"}, "confidence": 0.9, "warnings": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
result = asyncio.run(analyze_drawing_ai(parsed, provider=_FakeProvider(payload)))
|
|
|
|
|
|
assert result["status"] == "AI_ANALYZED"
|
|
|
|
|
|
assert result["deterministic"] is False
|
|
|
|
|
|
assert result["analysis"]["drawingNumber"] == "AI-001"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ai_analysis_falls_back_when_provider_missing_or_invalid():
|
|
|
|
|
|
parsed = {
|
|
|
|
|
|
"asset": {"sha256": "0" * 64, "filename": "AI-001-a.dxf"},
|
|
|
|
|
|
"drawing": {"drawingNumber": "AI-001", "revision": "a"},
|
|
|
|
|
|
"fieldCandidates": [], "texts": [], "itemReferences": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
missing = asyncio.run(analyze_drawing_ai(parsed, provider=None))
|
|
|
|
|
|
assert missing["status"] == "AI_UNAVAILABLE"
|
|
|
|
|
|
assert missing["deterministic"] is True
|
|
|
|
|
|
|
|
|
|
|
|
invalid = asyncio.run(analyze_drawing_ai(parsed, provider=_FakeProvider("not json")))
|
|
|
|
|
|
assert invalid["status"] == "AI_UNAVAILABLE"
|
|
|
|
|
|
assert "message" in invalid
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ai_analysis_parses_double_encoded_and_markdown_json():
|
|
|
|
|
|
parsed = {
|
|
|
|
|
|
"asset": {"sha256": "0" * 64, "filename": "AI-001-a.dxf"},
|
|
|
|
|
|
"drawing": {"drawingNumber": "AI-001", "revision": "a"},
|
|
|
|
|
|
"fieldCandidates": [], "texts": [], "itemReferences": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
double_encoded = asyncio.run(
|
|
|
|
|
|
analyze_drawing_ai(parsed, provider=_FakeProvider('{"drawingNumber": "AI-001", "warnings": []}'))
|
|
|
|
|
|
)
|
|
|
|
|
|
assert double_encoded["status"] == "AI_ANALYZED"
|
|
|
|
|
|
assert double_encoded["analysis"]["drawingNumber"] == "AI-001"
|
|
|
|
|
|
|
|
|
|
|
|
markdown = asyncio.run(
|
|
|
|
|
|
analyze_drawing_ai(parsed, provider=_FakeProvider('```json\n{"drawingNumber": "AI-001", "warnings": []}\n```'))
|
|
|
|
|
|
)
|
|
|
|
|
|
assert markdown["status"] == "AI_ANALYZED"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _TextFallbackProvider(_FakeProvider):
|
|
|
|
|
|
def __init__(self, text):
|
|
|
|
|
|
super().__init__(None)
|
|
|
|
|
|
self.text = text
|
|
|
|
|
|
|
|
|
|
|
|
async def chat_text(self, system, user, timeout=45.0):
|
|
|
|
|
|
return self.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ai_analysis_retries_with_chat_text_when_json_invalid():
|
|
|
|
|
|
parsed = {
|
|
|
|
|
|
"asset": {"sha256": "0" * 64, "filename": "AI-001-a.dxf"},
|
|
|
|
|
|
"drawing": {"drawingNumber": "AI-001", "revision": "a"},
|
|
|
|
|
|
"fieldCandidates": [], "texts": [], "itemReferences": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
provider = _TextFallbackProvider('```json\n{"drawingNumber": "AI-001", "warnings": []}\n```')
|
|
|
|
|
|
result = asyncio.run(analyze_drawing_ai(parsed, provider=provider))
|
|
|
|
|
|
assert result["status"] == "AI_ANALYZED"
|
|
|
|
|
|
assert result["analysis"]["drawingNumber"] == "AI-001"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _auth_client(monkeypatch: pytest.MonkeyPatch, tenant: str) -> TestClient:
|
|
|
|
|
|
install_test_auth(monkeypatch, tenant)
|
|
|
|
|
|
client = TestClient(create_app())
|
|
|
|
|
|
login = client.post("/api/auth/login", json={"username": "planner", "password": "test"})
|
|
|
|
|
|
assert login.status_code == 200, login.text
|
|
|
|
|
|
return client
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _create_project(client: TestClient, name: str, work_dir: Path) -> str:
|
|
|
|
|
|
created = client.post("/api/projects", json={"name": name, "workDir": str(work_dir)})
|
|
|
|
|
|
assert created.status_code == 200, created.text
|
|
|
|
|
|
return created.json()["project"]["id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _inspect(client: TestClient, pid: str, path: Path) -> str:
|
|
|
|
|
|
resp = client.post("/api/drawings/inspect", json={"path": str(path), "projectId": pid})
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
return (resp.json().get("parsed") or resp.json()["drawing"])["asset"]["id"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delete_drawing_clears_asset_and_candidates(monkeypatch, tmp_path: Path):
|
|
|
|
|
|
sample = _sample(_DEFAULT_SAMPLE)
|
|
|
|
|
|
client = _auth_client(monkeypatch, "tenant-drawing-delete")
|
|
|
|
|
|
pid = _create_project(client, "图纸删除验收", _DEFAULT_SAMPLE.parent)
|
|
|
|
|
|
drawing_id = _inspect(client, pid, sample)
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.delete(f"/api/drawings/{drawing_id}")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert resp.json()["deleted"] is True
|
|
|
|
|
|
listing = client.get(f"/api/projects/{pid}/drawings")
|
|
|
|
|
|
assert listing.status_code == 200
|
|
|
|
|
|
assert listing.json()["count"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_clear_project_drawings_removes_all(monkeypatch, tmp_path: Path):
|
|
|
|
|
|
first = _sample(_DEFAULT_SAMPLE)
|
|
|
|
|
|
second = _sample(_SAMPLE_003)
|
|
|
|
|
|
client = _auth_client(monkeypatch, "tenant-drawing-clear")
|
|
|
|
|
|
pid = _create_project(client, "图纸清空验收", _SAMPLE_003.parent)
|
|
|
|
|
|
_inspect(client, pid, first)
|
|
|
|
|
|
_inspect(client, pid, second)
|
|
|
|
|
|
|
|
|
|
|
|
resp = client.delete(f"/api/projects/{pid}/drawings")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert resp.json()["deleted"] == 2
|
|
|
|
|
|
listing = client.get(f"/api/projects/{pid}/drawings")
|
|
|
|
|
|
assert listing.json()["count"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_two_mb_upload_is_not_blocked_by_app_limit(monkeypatch, tmp_path: Path):
|
|
|
|
|
|
client = _auth_client(monkeypatch, "tenant-large-upload")
|
|
|
|
|
|
pid = _create_project(client, "大文件上传验收", tmp_path)
|
|
|
|
|
|
payload = b"0\nSECTION\n2\nHEADER\n0\nENDSEC\n0\nEOF\n" + b"x" * 2_500_000
|
|
|
|
|
|
resp = client.post(
|
|
|
|
|
|
f"/api/projects/{pid}/files/upload",
|
|
|
|
|
|
files=[("files", ("large-001-a.dxf", payload, "application/octet-stream"))],
|
|
|
|
|
|
)
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert resp.json()["saved"] == ["large-001-a.dxf"]
|
|
|
|
|
|
assert (tmp_path / "large-001-a.dxf").stat().st_size == len(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_process_text_analysis_endpoint(monkeypatch):
|
|
|
|
|
|
client = _auth_client(monkeypatch, "tenant-process-text")
|
|
|
|
|
|
resp = client.post("/api/drawings/analyze-text", json={"text": "烟囱(9005)分段在上建场地拼板装焊,完成焊接后翻身形成组立。"})
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
body = resp.json()
|
|
|
|
|
|
assert body["status"] == "PENDING_REVIEW"
|
|
|
|
|
|
assert any(row["blockCode"] == "9005" for row in body["blocks"])
|
|
|
|
|
|
assert any(row["processName"] == "装焊" for row in body["operations"])
|
|
|
|
|
|
assert body["sequenceSteps"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_drawing_ai_api_endpoint(monkeypatch, tmp_path: Path):
|
|
|
|
|
|
sample = _sample(_DEFAULT_SAMPLE)
|
|
|
|
|
|
client = _auth_client(monkeypatch, "tenant-drawing-ai")
|
|
|
|
|
|
pid = _create_project(client, "图纸 AI 验收", _DEFAULT_SAMPLE.parent)
|
|
|
|
|
|
drawing_id = _inspect(client, pid, sample)
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
|
"server.agent_core.providers.get_provider",
|
|
|
|
|
|
lambda: _FakeProvider({"drawingNumber": "5060102101-001", "revision": "e", "confidence": 0.9, "warnings": []}),
|
|
|
|
|
|
)
|
|
|
|
|
|
resp = client.post(f"/api/drawings/{drawing_id}/ai-analyze")
|
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
|
assert resp.json()["status"] == "AI_ANALYZED"
|
|
|
|
|
|
assert resp.json()["analysis"]["drawingNumber"] == "5060102101-001"
|
|
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("server.agent_core.providers.get_provider", lambda: _FakeProvider(None))
|
|
|
|
|
|
fallback = client.post(f"/api/drawings/{drawing_id}/ai-analyze")
|
|
|
|
|
|
assert fallback.status_code == 200, fallback.text
|
|
|
|
|
|
assert fallback.json()["status"] == "AI_UNAVAILABLE"
|
|
|
|
|
|
assert fallback.json()["deterministic"] is True
|