Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
204 lines
11 KiB
Python
Executable File
204 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Upload one temporary acceptance document, verify retrieval, then delete it.
|
|
|
|
Service mutations use only 127.0.0.1:18080 and the KB created by this run.
|
|
Milvus access, when enabled, is restricted to read-only entities/query calls.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
from milvus_verify_acceptance import (ACCEPTANCE, BASE, MODES, NoRedirect,
|
|
VerificationError, inspect_results,
|
|
read_object, search_payload)
|
|
|
|
|
|
class SmokeClient:
|
|
def __init__(self):
|
|
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
|
|
self.token = ''
|
|
self.kb_id = None
|
|
self.knowledge_id = None
|
|
|
|
def request(self, route, method='GET', payload=None, raw=None, content_type=None):
|
|
kb = '/api/v1/knowledge-bases/' + str(self.kb_id)
|
|
knowledge = '/api/v1/knowledge/' + str(self.knowledge_id)
|
|
allowed = {('POST', '/api/v1/auth/login'), ('POST', '/api/v1/knowledge-bases')}
|
|
if self.kb_id:
|
|
allowed.update({('POST', kb + '/knowledge/file'), ('POST', kb + '/hybrid-search'),
|
|
('DELETE', kb), ('GET', kb)})
|
|
if self.knowledge_id:
|
|
allowed.add(('GET', knowledge))
|
|
if (method, route) not in allowed:
|
|
raise VerificationError('Request is outside the temporary resource allowlist')
|
|
headers = {'Accept': 'application/json'}
|
|
if self.token:
|
|
headers['Authorization'] = 'Bearer ' + self.token
|
|
if payload is not None:
|
|
raw = json.dumps(payload).encode()
|
|
content_type = 'application/json'
|
|
if content_type:
|
|
headers['Content-Type'] = content_type
|
|
started = time.monotonic()
|
|
request = urllib.request.Request(BASE + route, data=raw, headers=headers, method=method)
|
|
try:
|
|
try:
|
|
response = self.opener.open(request, timeout=45)
|
|
except urllib.error.HTTPError as error:
|
|
response = error
|
|
with response:
|
|
status = response.code
|
|
body = response.read(8 * 1024 * 1024)
|
|
value = json.loads(body) if status in (200, 201, 202) else None
|
|
except (OSError, ValueError):
|
|
raise VerificationError('Acceptance request failed or timed out') from None
|
|
return status, value, round((time.monotonic() - started) * 1000, 2)
|
|
|
|
def count_vectors(self, collection):
|
|
if collection not in ('yawan_acceptance_hybrid_512', 'weknora_embeddings_512') or not self.kb_id:
|
|
raise VerificationError('Milvus query is outside the allowed collections')
|
|
request = urllib.request.Request('http://127.0.0.1:19530/v2/vectordb/entities/query',
|
|
data=json.dumps({'collectionName': collection,
|
|
'filter': 'knowledge_base_id == ' + json.dumps(self.kb_id),
|
|
'outputFields': ['count(*)'], 'consistencyLevel': 'Strong'}).encode(),
|
|
headers={'Content-Type': 'application/json'})
|
|
try:
|
|
with self.opener.open(request, timeout=30) as response:
|
|
value = json.load(response)
|
|
if value.get('code') != 0:
|
|
raise VerificationError('Milvus count query failed')
|
|
return int(value['data'][0]['count(*)'])
|
|
except (OSError, ValueError, KeyError, IndexError):
|
|
raise VerificationError('Milvus count query failed or returned an invalid shape') from None
|
|
|
|
|
|
def run(args, report):
|
|
credentials = read_object(ACCEPTANCE / 'migration-token.json', 'Migration credentials')
|
|
state = read_object(ACCEPTANCE / 'legacy-migration-state.json', 'Migration state')
|
|
client = SmokeClient()
|
|
status, login, _ = client.request('/api/v1/auth/login', 'POST',
|
|
{'email': credentials['email'], 'password': credentials['password']})
|
|
if status != 200 or login.get('active_tenant', {}).get('id') != state['tenant_id']:
|
|
raise VerificationError('Login failed or used an unexpected tenant')
|
|
client.token = login['token']
|
|
suffix = uuid.uuid4().hex[:12]
|
|
kb_name = '临时验收-新上传检索-' + suffix
|
|
try:
|
|
status, value, latency = client.request('/api/v1/knowledge-bases', 'POST', {
|
|
'name': kb_name, 'description': 'Temporary acceptance-only upload smoke test',
|
|
'type': 'document', 'embedding_model_id': state['model_id'],
|
|
'summary_model_id': '',
|
|
'chunking_config': {'chunk_size': 512, 'chunk_overlap': 0},
|
|
'indexing_strategy': {'vector_enabled': True, 'keyword_enabled': True,
|
|
'wiki_enabled': False, 'graph_enabled': False},
|
|
'storage_provider_config': {'provider': 'local'},
|
|
'auto_tag_config': {'enabled': False},
|
|
})
|
|
if status != 201 or not isinstance(value.get('data'), dict):
|
|
raise VerificationError('Temporary knowledge base creation failed')
|
|
client.kb_id = value['data']['id']
|
|
report.update(kb_id=client.kb_id, create_ms=latency)
|
|
migrated_ids = {row['id'] for row in state['knowledge_bases'].values()}
|
|
if client.kb_id in migrated_ids:
|
|
client.kb_id = None
|
|
raise VerificationError('Created KB unexpectedly matched protected migration IDs')
|
|
if args.verify_milvus:
|
|
report['vectors_before'] = {name: client.count_vectors(name) for name in
|
|
('yawan_acceptance_hybrid_512', 'weknora_embeddings_512')}
|
|
if any(report['vectors_before'].values()):
|
|
raise VerificationError('Fresh temporary KB unexpectedly already has vectors')
|
|
boundary = 'acceptance' + uuid.uuid4().hex
|
|
document = ('# 资料归档演练\n\n编号:' + suffix + '\n\n'
|
|
'资料归档采用三步流程:先登记文件名称,再核对目录分类,最后确认文件可以检索。'
|
|
'检索测试使用资料归档和目录核对两个关键词。完成演练后删除临时资料,不影响其他知识库。\n')
|
|
raw = ('--' + boundary + '\r\nContent-Disposition: form-data; name="file"; '
|
|
'filename="acceptance-upload-' + suffix + '.md"\r\n'
|
|
'Content-Type: text/markdown; charset=utf-8\r\n\r\n' + document +
|
|
'\r\n--' + boundary + '--\r\n').encode('utf-8')
|
|
status, value, latency = client.request('/api/v1/knowledge-bases/' + client.kb_id + '/knowledge/file',
|
|
'POST', raw=raw, content_type='multipart/form-data; boundary=' + boundary)
|
|
if status not in (200, 201) or not isinstance(value.get('data'), dict):
|
|
raise VerificationError('Temporary file upload failed')
|
|
client.knowledge_id = value['data']['id']
|
|
report.update(knowledge_id=client.knowledge_id, upload_ms=latency, file_bytes=len(document.encode('utf-8')))
|
|
started = time.monotonic()
|
|
while time.monotonic() - started < args.index_timeout:
|
|
status, value, _ = client.request('/api/v1/knowledge/' + client.knowledge_id)
|
|
parse_status = value.get('data', {}).get('parse_status') if isinstance(value, dict) else None
|
|
if parse_status == 'completed':
|
|
break
|
|
if parse_status in ('failed', 'cancelled', 'deleting') or status != 200:
|
|
raise VerificationError('Temporary file indexing failed')
|
|
time.sleep(2)
|
|
else:
|
|
raise VerificationError('Temporary file indexing exceeded its deadline')
|
|
report['indexing_seconds'] = round(time.monotonic() - started, 2)
|
|
for mode in MODES:
|
|
status, value, latency = client.request('/api/v1/knowledge-bases/' + client.kb_id + '/hybrid-search',
|
|
'POST', search_payload('资料归档 目录核对', mode, 5))
|
|
result = inspect_results(value, client.kb_id, 5, mode) if status == 200 else {'passed': False}
|
|
result.update(mode=mode, status=status, latency_ms=latency)
|
|
report['retrieval'].append(result)
|
|
if args.verify_milvus:
|
|
report['vectors_after_upload'] = {name: client.count_vectors(name) for name in
|
|
('yawan_acceptance_hybrid_512', 'weknora_embeddings_512')}
|
|
report['isolated_vector_write'] = (report['vectors_after_upload']['yawan_acceptance_hybrid_512'] > 0
|
|
and report['vectors_after_upload']['weknora_embeddings_512'] == 0)
|
|
finally:
|
|
if client.kb_id:
|
|
status, _, latency = client.request('/api/v1/knowledge-bases/' + client.kb_id, 'DELETE')
|
|
report['cleanup'] = {'delete_status': status, 'delete_ms': latency, 'passed': False}
|
|
if status not in (200, 202, 204):
|
|
raise VerificationError('Temporary KB cleanup request failed')
|
|
status, _, _ = client.request('/api/v1/knowledge-bases/' + client.kb_id)
|
|
report['cleanup']['kb_get_status'] = status
|
|
report['cleanup']['passed'] = status == 404
|
|
if args.verify_milvus:
|
|
started = time.monotonic()
|
|
while time.monotonic() - started < 90:
|
|
counts = {name: client.count_vectors(name) for name in
|
|
('yawan_acceptance_hybrid_512', 'weknora_embeddings_512')}
|
|
if all(count == 0 for count in counts.values()):
|
|
break
|
|
time.sleep(2)
|
|
report['cleanup'].update(vector_counts=counts,
|
|
vector_cleanup_seconds=round(time.monotonic() - started, 2))
|
|
report['cleanup']['passed'] = report['cleanup']['passed'] and all(count == 0 for count in counts.values())
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--verify-milvus', action='store_true', help='Read counts for this temporary KB from acceptance and production collections')
|
|
parser.add_argument('--output', type=Path)
|
|
parser.add_argument('--index-timeout', type=int, default=180)
|
|
args = parser.parse_args()
|
|
if not 10 <= args.index_timeout <= 600:
|
|
parser.error('index-timeout must be 10-600 seconds')
|
|
if args.output and args.output.resolve() in {file.resolve() for file in
|
|
(ACCEPTANCE / 'migration-token.json', ACCEPTANCE / 'legacy-migration-state.json', ACCEPTANCE / 'browser-auth.json')}:
|
|
parser.error('Output must not overwrite existing authentication or migration state')
|
|
report = {'acceptance_url': BASE, 'retrieval': [], 'failures': []}
|
|
try:
|
|
run(args, report)
|
|
except VerificationError as error:
|
|
report['failures'].append(str(error))
|
|
except Exception:
|
|
report['failures'].append('Unexpected smoke-test failure; no server error or credentials included')
|
|
report['passed'] = (not report['failures'] and len(report['retrieval']) == 3
|
|
and all(row['passed'] for row in report['retrieval'])
|
|
and report.get('cleanup', {}).get('passed', False)
|
|
and (not args.verify_milvus or report.get('isolated_vector_write', False)))
|
|
text = json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + '\n'
|
|
if args.output:
|
|
args.output.write_text(text, encoding='utf-8')
|
|
print(text, end='')
|
|
return 0 if report['passed'] else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|