mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 20:35:22 +08:00
279 lines
11 KiB
Python
279 lines
11 KiB
Python
"""Finite durable-state lifecycle against the runner's disposable Core and Runtime."""
|
|
|
|
import copy
|
|
import json
|
|
from pathlib import Path
|
|
import secrets
|
|
import time
|
|
|
|
from regression_api_checks import check, request
|
|
from regression_coverage import checked
|
|
from regression_lifecycle_cases import after_id, completed
|
|
from regression_local_cases import require
|
|
from regression_smoke import reconcile_scenario_runs
|
|
from regression_suite import Suite, contains_fields, resolve, validate
|
|
from regression_traffic import group_request
|
|
|
|
|
|
def state_profile(profile):
|
|
"""Keep only the fixtures used by the dependent lifecycle chain."""
|
|
profile = copy.deepcopy(profile)
|
|
wanted = {
|
|
'mock_provider',
|
|
'embedding_provider',
|
|
'chat_model',
|
|
'unicode_model',
|
|
'embedding_model',
|
|
'empty_knowledge_base',
|
|
'chat_pipeline',
|
|
'chat_bot',
|
|
}
|
|
profile['resources'] = [item for item in profile['resources'] if item['id'] in wanted]
|
|
require({item['id'] for item in profile['resources']} == wanted, 'State lifecycle fixtures are incomplete')
|
|
profile.update(coverage_suite='state', cleanup='always')
|
|
profile['cases'] = [
|
|
{
|
|
'name': 'durable_state_lifecycle',
|
|
'stage': 'state',
|
|
'expect': 'LANGBOT_CHAT_OK',
|
|
'http_group': {
|
|
'url': '${core_base_url}/bots/${chat_bot}/sync',
|
|
'secret_env': 'LANGBOT_QA_GROUP_SECRET',
|
|
'message': 'ai Run the durable state scenario',
|
|
},
|
|
}
|
|
]
|
|
return profile
|
|
|
|
|
|
def document_visible(results, document, marker):
|
|
return any(
|
|
item.get('metadata', {}).get('file_id') == document['engine_document_id']
|
|
and marker in json.dumps(item.get('content', []), ensure_ascii=False)
|
|
for item in results
|
|
)
|
|
|
|
|
|
def document_absent(results, document, marker):
|
|
return all(
|
|
item.get('metadata', {}).get('file_id') != document['engine_document_id']
|
|
and marker not in json.dumps(item, ensure_ascii=False)
|
|
for item in results
|
|
)
|
|
|
|
|
|
def run_state_checks(profile, profile_path, output, env, restart_owned, budget=300):
|
|
"""Exercise create/restart/update/restart/delete while retaining exact business evidence."""
|
|
validate(profile)
|
|
output = Path(output)
|
|
output.mkdir(parents=True, exist_ok=False, mode=0o700)
|
|
started = time.monotonic()
|
|
deadline = started + budget - 30
|
|
suite = Suite(profile, profile_path, output, env)
|
|
|
|
def timeout(maximum):
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError('State lifecycle exhausted its execution deadline')
|
|
return min(maximum, remaining)
|
|
|
|
suite.timeout = timeout
|
|
suite.write('profile.json', profile)
|
|
row = {'case': 'durable_state_lifecycle', 'stage': 'state', 'status': 'unknown', 'restarts': []}
|
|
result = {'mode': 'state', 'cases': [row], 'errors': [], 'incomplete': [], 'phases': []}
|
|
|
|
def restart(service, phase):
|
|
receipt = restart_owned(service, timeout=timeout(60))
|
|
check(
|
|
row,
|
|
phase + '_process_replaced',
|
|
bool(receipt.get('old_pid')) and bool(receipt.get('new_pid')) and receipt['old_pid'] != receipt['new_pid'],
|
|
'Owned restart did not replace its process',
|
|
)
|
|
row['restarts'].append({'phase': phase, **receipt})
|
|
|
|
try:
|
|
suite.prepare()
|
|
row['after_id'] = after_id(suite)
|
|
case = resolve(profile['cases'][0], suite.variables)
|
|
pipeline_path = '/api/v1/pipelines/' + suite.variables['chat_pipeline']
|
|
kb_path = '/api/v1/knowledge/bases/' + suite.variables['empty_knowledge_base']
|
|
pipeline = request(suite, row, 'GET', pipeline_path)['pipeline']
|
|
submitted = resolve(
|
|
next(r for r in profile['resources'] if r['id'] == 'chat_pipeline')['body'], suite.variables
|
|
)
|
|
check(
|
|
row,
|
|
'created_configuration',
|
|
contains_fields(pipeline, submitted),
|
|
'Pipeline differs from created configuration',
|
|
)
|
|
expected_pipeline = {
|
|
key: pipeline[key] for key in ('uuid', 'name', 'description', 'config', 'extensions_preferences')
|
|
}
|
|
|
|
def configuration(phase):
|
|
current = request(suite, row, 'GET', pipeline_path)['pipeline']
|
|
check(
|
|
row,
|
|
phase + '_configuration',
|
|
contains_fields(current, expected_pipeline),
|
|
'Persisted pipeline configuration changed',
|
|
)
|
|
|
|
def files():
|
|
return request(suite, row, 'GET', kb_path + '/files')['files']
|
|
|
|
def ingest(label, marker):
|
|
previous = {item['uuid'] for item in files()}
|
|
document = output / (label + '.txt')
|
|
document.write_text(f'Durable lifecycle document {label}: {marker}.\n', encoding='utf-8')
|
|
suite.call(
|
|
[
|
|
'knowledge-base',
|
|
'ingest',
|
|
suite.variables['empty_knowledge_base'],
|
|
'--file',
|
|
str(document),
|
|
'--parser-plugin-id',
|
|
'langbot-team/GeneralParsers',
|
|
'--wait',
|
|
]
|
|
)
|
|
added = [item for item in files() if item['uuid'] not in previous]
|
|
check(
|
|
row,
|
|
label + '_ingested',
|
|
len(added) == 1 and added[0].get('status') == 'completed' and bool(added[0].get('engine_document_id')),
|
|
'Expected exactly one completed document',
|
|
)
|
|
row.setdefault('documents', {})[label] = {
|
|
'uuid': added[0]['uuid'],
|
|
'engine_document_id': added[0]['engine_document_id'],
|
|
'marker': marker,
|
|
}
|
|
return added[0]
|
|
|
|
markers = {name: name + secrets.token_hex(16) for name in ('original', 'control', 'replacement')}
|
|
documents = {name: ingest(name, markers[name]) for name in ('original', 'control')}
|
|
session, previous, turn = 'qa-state-' + secrets.token_hex(16), 'none', 0
|
|
|
|
def chat(phase):
|
|
nonlocal previous, turn
|
|
token = secrets.token_hex(16)
|
|
after = after_id(suite)
|
|
turn += 1
|
|
checked(
|
|
row,
|
|
phase + '_chat_history',
|
|
lambda: group_request(
|
|
case,
|
|
session,
|
|
'qa-state-member',
|
|
turn,
|
|
token,
|
|
previous,
|
|
suite.env,
|
|
timeout=timeout(30),
|
|
),
|
|
)
|
|
completed(suite, row, session, after)
|
|
previous = token
|
|
|
|
def retrieval(phase, *, updated=False):
|
|
found = request(
|
|
suite,
|
|
row,
|
|
'POST',
|
|
kb_path + '/retrieve',
|
|
{
|
|
'query': ' '.join(markers.values()),
|
|
'retrieval_settings': {'top_k': 10},
|
|
},
|
|
)['results']
|
|
check(
|
|
row,
|
|
phase + '_retrieval_shape',
|
|
isinstance(found, list)
|
|
and all(
|
|
isinstance(item, dict)
|
|
and isinstance(item.get('metadata'), dict)
|
|
and isinstance(item.get('content'), list)
|
|
for item in found
|
|
),
|
|
'Malformed retrieval response',
|
|
)
|
|
selected = ('replacement', 'control') if updated else ('original', 'control')
|
|
check(
|
|
row,
|
|
phase + '_retrievable',
|
|
all(document_visible(found, documents[name], markers[name]) for name in selected),
|
|
'Persisted documents are missing or return incorrect content',
|
|
)
|
|
if updated:
|
|
check(
|
|
row,
|
|
phase + '_obsolete_absent',
|
|
document_absent(found, documents['original'], markers['original'])
|
|
and documents['original']['uuid'] not in {item['uuid'] for item in files()},
|
|
'Replaced document is still visible through retrieval or file listing',
|
|
)
|
|
row.setdefault('retrievals', []).append({'phase': phase, 'results': found})
|
|
|
|
chat('created')
|
|
retrieval('created')
|
|
for service in ('runtime', 'core'):
|
|
restart(service, 'initial_' + service)
|
|
configuration('after_' + service)
|
|
chat('after_' + service)
|
|
retrieval('after_' + service)
|
|
|
|
updated = copy.deepcopy(expected_pipeline['config'])
|
|
updated['ai']['runner_config']['plugin:langbot-team/LocalAgent/default']['model']['primary'] = suite.variables[
|
|
'unicode_model'
|
|
]
|
|
request(suite, row, 'PUT', pipeline_path, {'description': 'Updated durable state fixture', 'config': updated})
|
|
expected_pipeline.update(description='Updated durable state fixture', config=updated)
|
|
configuration('updated')
|
|
case['expect'] = 'LANGBOT_UNICODE_OK'
|
|
case['http_group']['message'] = 'ai 请原样处理:你好,世界 🌍'
|
|
chat('updated')
|
|
documents['replacement'] = ingest('replacement', markers['replacement'])
|
|
request(suite, row, 'DELETE', kb_path + '/files/' + documents['original']['uuid'])
|
|
retrieval('updated', updated=True)
|
|
restart('core', 'updated_core')
|
|
configuration('persisted_update')
|
|
chat('persisted_update')
|
|
retrieval('persisted_update', updated=True)
|
|
for name in ('replacement', 'control'):
|
|
request(suite, row, 'DELETE', kb_path + '/files/' + documents[name]['uuid'])
|
|
check(row, 'documents_deleted', not files(), 'Deleted document metadata remains visible')
|
|
found = request(
|
|
suite,
|
|
row,
|
|
'POST',
|
|
kb_path + '/retrieve',
|
|
{
|
|
'query': ' '.join(markers.values()),
|
|
'retrieval_settings': {'top_k': 10},
|
|
},
|
|
)['results']
|
|
check(row, 'deleted_retrieval_empty', found == [], 'Deleted knowledge content remains retrievable')
|
|
reconcile_scenario_runs(suite, result, key='ledger_closeout')
|
|
require(not result['errors'], 'Lifecycle run ledger did not reconcile')
|
|
row['status'] = 'passed'
|
|
except Exception as exc:
|
|
row.update(status='unknown' if isinstance(exc, (TimeoutError, OSError)) else 'failed', error=str(exc))
|
|
result['errors'].append('state lifecycle: ' + str(exc))
|
|
if row['status'] == 'unknown':
|
|
result['incomplete'].append(result['errors'][-1])
|
|
finally:
|
|
row['seconds'] = time.monotonic() - started
|
|
suite.write('cases.json', result['cases'])
|
|
# Keep a bounded cleanup window even when the business chain reaches its deadline.
|
|
deadline = time.monotonic() + 30
|
|
suite.finish(result)
|
|
result.update(elapsed_seconds=time.monotonic() - started, case_count=1, requests=1)
|
|
suite.write('summary.json', result)
|
|
return result
|