Skip to content

Commit 83a045b

Browse files
committed
feat: implement workflow web images and storage simulation with HTTP endpoints
1 parent 85a20b5 commit 83a045b

6 files changed

Lines changed: 236 additions & 3 deletions

File tree

‎apps/application/tests/README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# 工作流测试
2+
3+
工作流 Web 图片导入的 HTTP 回归使用 Postman CLI 1.70 或更高版本:
4+
5+
```bash
6+
postman simulation run apps/application/tests/fixtures/workflow_web_images.sim.yaml --port 49643 --no-history
7+
```
8+
9+
另一个终端运行:
10+
11+
```bash
12+
WORKFLOW_WEB_IMAGE_TEST_URL=http://127.0.0.1:49643 \
13+
uv run python apps/manage.py test application.tests.test_workflow_web_images
14+
```
15+
16+
测试通过真实网页抓取执行器和 boto3 客户端访问本地网页、图片和 S3 对象存储模拟,
17+
覆盖抓取、图片下载、保存、分段、文档写入及段落附件关联。重复导入复用已保存的图片;
18+
下载失败保留原链接,正文仍可导入。测试数据为合成内容,不调用模型。
19+
20+
网页模拟提供普通 HTTP GET;对象存储模拟仅覆盖 S3 PutObject、GetObject 和 DeleteObject,
21+
不验证真实站点的访问权限、重定向或远端存储服务。未设置环境变量时跳过两项 HTTP 回归,
22+
其余工作流图片测试照常运行。
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Synthetic website and S3 object storage for the actual workflow import pipeline.
2+
// HTTP image downloads use ordinary GET; S3 PutObject/GetObject uses path-style bucket keys.
3+
const http = require("node:http");
4+
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aS1EAAAAASUVORK5CYII=", "base64");
5+
6+
http.createServer(async (request, response) => {
7+
const path = new URL(request.url, "http://localhost").pathname;
8+
const send = (status, type, body) => {
9+
response.writeHead(status, { "Content-Type": type });
10+
response.end(body);
11+
};
12+
if (request.method === "GET" && path === "/page.html") {
13+
return send(200, "text/html; charset=utf-8", '<html><body><h1>Chart</h1><p>Imported text</p><img src="chart.png" alt="first"><img src="chart.png" alt="again"></body></html>');
14+
}
15+
if (request.method === "GET" && path === "/missing.html") {
16+
return send(200, "text/html; charset=utf-8", '<html><body><p>Keep this text</p><img src="missing.png" alt="missing"></body></html>');
17+
}
18+
if (request.method === "GET" && path === "/chart.png") return send(200, "image/png", png);
19+
if (request.method === "GET" && path === "/missing.png") return send(404, "text/plain", "Not found");
20+
return send(501, "text/plain", "Endpoint not modeled");
21+
}).listen(process.env.PORT, "127.0.0.1");
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
simulation: workflow-web-images
2+
routing: path
3+
mocks:
4+
- routeKey: site
5+
path: ./workflow_web_images.js
6+
- routeKey: storage
7+
path: ./workflow_web_storage.js
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Minimal path-style S3 PutObject/GetObject fixture; stored bytes are synthetic test data.
2+
const http = require("node:http");
3+
const crypto = require("node:crypto");
4+
http.createServer(async (request, response) => {
5+
const path = new URL(request.url, "http://localhost").pathname;
6+
const send = (status, type, body) => {
7+
response.writeHead(status, { "Content-Type": type });
8+
response.end(body);
9+
};
10+
if (path.startsWith("/bucket/")) {
11+
const key = "workflow-web-images:" + path;
12+
if (request.method === "PUT") {
13+
const chunks = [];
14+
for await (const chunk of request) chunks.push(chunk);
15+
const data = Buffer.concat(chunks);
16+
await pm.state.set(key, data.toString("base64"));
17+
response.writeHead(200, { ETag: '"' + crypto.createHash("md5").update(data).digest("hex") + '"' });
18+
return response.end();
19+
}
20+
if (request.method === "GET") {
21+
const data = await pm.state.get(key);
22+
if (data !== undefined && data !== null) return send(200, "application/octet-stream", Buffer.from(data, "base64"));
23+
return send(404, "application/xml", "<Error><Code>NoSuchKey</Code></Error>");
24+
}
25+
if (request.method === "DELETE") {
26+
await pm.state.set(key, null);
27+
response.writeHead(204);
28+
return response.end();
29+
}
30+
}
31+
return send(501, "text/plain", "Endpoint not modeled");
32+
}).listen(process.env.PORT, "127.0.0.1");
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Workflow Web sources persist images before splitting and bind them on write."""
2+
3+
import os
4+
from unittest import skipUnless
5+
from unittest.mock import MagicMock, patch
6+
from uuid import uuid4
7+
8+
import boto3
9+
from botocore.config import Config
10+
from django.test import SimpleTestCase, TestCase
11+
12+
from application.workflow.common import WorkflowType
13+
from application.workflow.nodes.data_source_web_node.data_source_web_node import DataSourceWebNode
14+
from application.workflow.nodes.document_split_node.document_split_node import DocumentSplitNode
15+
from application.workflow.nodes.knowledge_write_node.knowledge_write_node import KnowledgeWriteNode
16+
from common.utils.fork import ChildLink, Fork
17+
from knowledge.models import File, FileSourceType, Knowledge, KnowledgeType, Paragraph, ParagraphAsset
18+
19+
20+
NODE_MODULE = "application.workflow.nodes.data_source_web_node.data_source_web_node"
21+
22+
23+
def source_node(parameters):
24+
node = DataSourceWebNode.__new__(DataSourceWebNode)
25+
node.get_workflow_parameters = MagicMock(return_value=parameters)
26+
node._check_cancelled = MagicMock()
27+
node.get_node_id = MagicMock(return_value="web")
28+
node.write_context = MagicMock()
29+
return node
30+
31+
32+
class WorkflowWebImageSourceTests(SimpleTestCase):
33+
def test_full_import_and_single_document_sync_localize_images_before_output(self):
34+
for single in (False, True):
35+
with self.subTest(single=single):
36+
parameters = {
37+
"knowledge_id": "knowledge-id",
38+
"data_source": {"source_url": "https://example.com/page.html"},
39+
"workflow_source": {"source_scope": "web-scope"},
40+
}
41+
if single:
42+
parameters["workflow_sync_document"] = {"name": "existing title"}
43+
node = source_node(parameters)
44+
remote = "![chart](https://example.com/chart.png)"
45+
local = "![chart](./oss/file/00000000-0000-0000-0000-000000000001)"
46+
response = Fork.Response.success(remote, [])
47+
48+
def crawl(_depth, _visited, handler):
49+
handler(ChildLink(parameters["data_source"]["source_url"], None), response)
50+
51+
with (
52+
patch(f"{NODE_MODULE}.Fork") as fetch,
53+
patch(f"{NODE_MODULE}.ForkManage") as crawler,
54+
patch(f"{NODE_MODULE}.internalize_web_images", return_value=local) as internalize,
55+
):
56+
fetch.return_value.fork.return_value = response
57+
crawler.return_value.fork.side_effect = crawl
58+
node.execute()
59+
60+
internalize.assert_called_once_with(remote, "knowledge-id")
61+
document = node.write_context.call_args_list[0].args[1][0]
62+
self.assertEqual(document["content"], local)
63+
self.assertEqual(document["meta"]["source_type"], "web")
64+
self.assertEqual(document["meta"]["source_scope"], "web-scope")
65+
self.assertEqual(document["meta"]["source_url"], parameters["data_source"]["source_url"])
66+
if single:
67+
self.assertEqual(document["name"], "existing title")
68+
69+
def test_failed_page_does_not_download_images_or_publish_a_document(self):
70+
documents = []
71+
handler = source_node({})._get_collect_handler(documents, knowledge_id="knowledge-id")
72+
with patch(f"{NODE_MODULE}.internalize_web_images") as internalize:
73+
with self.assertRaisesRegex(ValueError, "unavailable"):
74+
handler(ChildLink("https://example.com/page.html", None), Fork.Response.error("unavailable"))
75+
internalize.assert_not_called()
76+
self.assertEqual(documents, [])
77+
78+
79+
@skipUnless(os.environ.get("WORKFLOW_WEB_IMAGE_TEST_URL"), "Start the local workflow Web Postman simulation")
80+
class WorkflowWebImageHTTPTests(TestCase):
81+
def setUp(self):
82+
self.base_url = os.environ["WORKFLOW_WEB_IMAGE_TEST_URL"].rstrip("/")
83+
self.knowledge = Knowledge.objects.create(name="Workflow Web images", desc="", type=KnowledgeType.WORKFLOW)
84+
self.parameters = {
85+
"knowledge_id": str(self.knowledge.id),
86+
"knowledge_action_id": str(uuid4()),
87+
"workflow_source": {"source_scope": "web-scope", "source_type": "web"},
88+
"data_source": {"source_url": f"{self.base_url}/site/page.html"},
89+
}
90+
client = boto3.client(
91+
"s3",
92+
endpoint_url=f"{self.base_url}/storage",
93+
aws_access_key_id="test-key",
94+
aws_secret_access_key="test-secret",
95+
region_name="us-east-1",
96+
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
97+
)
98+
self.enterContext(patch("knowledge.models.knowledge.get_s3_client", return_value=client))
99+
self.enterContext(patch("knowledge.models.knowledge.get_bucket", return_value="bucket"))
100+
self.enterContext(patch("knowledge.models.knowledge.is_seaweedfs_enabled", return_value=True))
101+
102+
def import_page(self):
103+
web = source_node(self.parameters)
104+
web.execute()
105+
downloaded = web.write_context.call_args_list[0].args[1]
106+
splitter = DocumentSplitNode.__new__(DocumentSplitNode)
107+
splitter.get_parameters = MagicMock(return_value={"document_list": ["web", "document_list"]})
108+
splitter.get_workflow_parameters = MagicMock(return_value=self.parameters)
109+
splitter.get_workflow_type = MagicMock(return_value=WorkflowType.KNOWLEDGE)
110+
splitter.get_reference_content = MagicMock(return_value=downloaded)
111+
splitter.write_context = MagicMock()
112+
splitter.execute()
113+
documents = next(
114+
call.args[1] for call in splitter.write_context.call_args_list if call.args[0] == "paragraph_list"
115+
)
116+
writer = KnowledgeWriteNode.__new__(KnowledgeWriteNode)
117+
writer.get_workflow_parameters = MagicMock(return_value=self.parameters)
118+
return writer.save(documents, None)[0][0]
119+
120+
def test_crawl_download_split_and_write_share_one_local_image(self):
121+
document = self.import_page()
122+
file = File.objects.get()
123+
self.assertEqual(file.source_type, FileSourceType.PARAGRAPH)
124+
self.assertTrue(Paragraph.objects.filter(id=file.source_id, document=document).exists())
125+
assets = list(ParagraphAsset.objects.filter(document=document))
126+
self.assertEqual(len(assets), 2)
127+
self.assertEqual({asset.file_id for asset in assets}, {file.id})
128+
self.assertTrue(file.get_bytes().startswith(b"\x89PNG\r\n\x1a\n"))
129+
paragraphs = list(Paragraph.objects.filter(document=document))
130+
self.assertFalse(any(f"{self.base_url}/site/chart.png" in p.content for p in paragraphs))
131+
self.assertTrue(any(f"/oss/file/{file.id}" in p.content for p in paragraphs))
132+
self.assertEqual(document.meta["source_url"], self.parameters["data_source"]["source_url"])
133+
second = self.import_page()
134+
self.assertEqual(File.objects.count(), 1)
135+
self.assertEqual(
136+
set(ParagraphAsset.objects.filter(document=second).values_list("file_id", flat=True)), {file.id}
137+
)
138+
139+
def test_missing_image_preserves_remote_reference_and_imports_text(self):
140+
self.parameters["data_source"]["source_url"] = f"{self.base_url}/site/missing.html"
141+
self.parameters["workflow_sync_document"] = {"name": "retained title"}
142+
document = self.import_page()
143+
self.assertEqual(document.name, "retained title")
144+
self.assertFalse(File.objects.exists())
145+
self.assertFalse(ParagraphAsset.objects.exists())
146+
self.assertTrue(Paragraph.objects.filter(document=document, content__contains="/site/missing.png").exists())

‎apps/application/workflow/nodes/data_source_web_node/data_source_web_node.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from application.workflow.i_node import INode
1717
from common.utils.fork import ChildLink, Fork, ForkManage
1818
from common.utils.logger import maxkb_logger
19+
from knowledge.web_assets import internalize_web_images
1920

2021

2122
class DataSourceWebNodeParamsSerializer(serializers.Serializer):
@@ -49,7 +50,7 @@ def get_form_list(node):
4950
},
5051
]
5152

52-
def _get_collect_handler(self, document_list, source_meta=None, selector="body"):
53+
def _get_collect_handler(self, document_list, source_meta=None, selector="body", knowledge_id=None):
5354
def handler(child_link: ChildLink, response: Fork.Response):
5455
if response.status != 200:
5556
raise ValueError(response.message or f"Failed to fetch Web source: {child_link.url}")
@@ -61,7 +62,9 @@ def handler(child_link: ChildLink, response: Fork.Response):
6162
document_list.append(
6263
{
6364
"name": document_name.strip(),
64-
"content": response.content,
65+
"content": internalize_web_images(response.content, knowledge_id)
66+
if knowledge_id is not None
67+
else response.content,
6568
"meta": {
6669
**(source_meta or {}),
6770
"source_type": "web",
@@ -86,7 +89,9 @@ def execute(self):
8689

8790
document_list = []
8891
source_meta = workflow_params.get("workflow_source") or {}
89-
collect_handler = self._get_collect_handler(document_list, source_meta, selector)
92+
collect_handler = self._get_collect_handler(
93+
document_list, source_meta, selector, knowledge_id=workflow_params.get("knowledge_id")
94+
)
9095

9196
try:
9297
sync_document = workflow_params.get("workflow_sync_document")

0 commit comments

Comments
 (0)