Simplify investigation replies and derive controller bookkeeping
This commit is contained in:
1 parent
57984e5793
commit
49095ac803
26 files changed
+2072
-207
No files matched your search
@@ -105,6 +105,8 @@ def test_invalid_review_is_atomic_before_assessment_or_tool_spending(tool_case):
|
||||
if context['previous_result']:
|
||||
reply['update']['causal_review']=review(against=['E999999'])
|
||||
loop=Investigator(store,gateway(LoopAdapter(change)),router=InvestigationToolRouter(project_research=service))
|
||||
original=loop._context
|
||||
loop._context=lambda *args:dict(original(*args),prompt_version='investigation_project_v13')
|
||||
run(loop,steps=3)
|
||||
before=BudgetManager(store).snapshot(*CASE).state
|
||||
result=asyncio.run(loop.step(*CASE))
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_flags_missing_values_conflicts_and_unicode_are_lossless():
|
||||
items[0]['text_truncated'] = True
|
||||
items[1]['text_truncated'] = 0 # Must not conflate 0 and false.
|
||||
del items[2]['text_truncated']
|
||||
items[3]['text'] = '\tUnicode ß with embedded\nnewline and \\ escapes'
|
||||
items[3]['text'] = '\tUnicode ß with embedded\nnewline and \\ escapes'
|
||||
view = checked(c)
|
||||
assert view['shared_sources']['FILE0001']['conflicting_lines'] == [41, 42, 43, 44]
|
||||
|
||||
@@ -68,7 +68,7 @@ def test_v6_prompt_is_unchanged_and_v7_uses_same_output_schema():
|
||||
assert new['messages'][1]['content'] == model_json({'task':'investigate_hypothesis', 'input':block_view(c), 'output_schema':new['output_schema']})
|
||||
|
||||
|
||||
@pytest.mark.parametrize('version',['investigation_project_v7','investigation_project_v10','investigation_project_v11','investigation_project_v12','investigation_project_v13'])
|
||||
@pytest.mark.parametrize('version',['investigation_project_v7','investigation_project_v10','investigation_project_v11','investigation_project_v12','investigation_project_v13','investigation_project_v14'])
|
||||
def test_new_file_aliases_resolve_before_the_normal_tool_validation(monkeypatch,version):
|
||||
from types import SimpleNamespace
|
||||
from viano_bug_analysis.analysis.investigator import Investigator
|
||||
|
||||
@@ -45,10 +45,10 @@ class LoopAdapter:
|
||||
result["next"]={"kind":"test","executor":"human","question":"What occurs after interaction?","steps":["Interact once"],
|
||||
"prerequisites":["Use the specified build"],"requested_observations":["Record the observed state"],"environment":"build-A"}
|
||||
if self.change:self.change(result,context)
|
||||
if request['prompt_version'] in {'investigation_project_v12','investigation_project_v13'}:
|
||||
if request['prompt_version'] in {'investigation_project_v12','investigation_project_v13','investigation_project_v14'}:
|
||||
result['update'].setdefault('causal_review',{'effect':'neutral','support':[],'against':[],
|
||||
'reason':'This fixture gathers context without establishing a causal defect.'})
|
||||
if request['prompt_version']=='investigation_project_v13':
|
||||
if request['prompt_version'] in {'investigation_project_v13','investigation_project_v14'}:
|
||||
existing=context.get('working_brief')
|
||||
review=result['update']['causal_review']
|
||||
findings=[]
|
||||
@@ -62,6 +62,16 @@ class LoopAdapter:
|
||||
'uncertainties':['Causal defect not established.'],'next_question':'Check the selected mechanism.',
|
||||
'changes':[{'id':fid,'reason':'Fixture revises the finding.',
|
||||
'evidence_refs':result['update']['evidence_refs']} for fid,f in old.items() if new.get(fid)!=f]})
|
||||
if request['prompt_version']=='investigation_project_v14':
|
||||
b=result['update']['working_brief']
|
||||
if b['format_version']==1:
|
||||
reasons={v['id']:v['reason'] for v in b.pop('changes')}
|
||||
b['format_version']=2
|
||||
for f in b['findings']:
|
||||
if f['id'] in reasons:f['revision_reason']=reasons[f['id']]
|
||||
b['retired']=[{'id':fid,'reason':reason} for fid,reason in reasons.items() if fid not in {f['id'] for f in b['findings']}]
|
||||
for k in ('evidence_refs','causal_review','note'):result['update'].pop(k,None)
|
||||
if result['assessment']:result['assessment'].pop('evidence_refs',None)
|
||||
return ProviderResponse(result,"fixture-investigation",100,50)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from viano_bug_analysis.analysis.investigation_decision import normalize
|
||||
from viano_bug_analysis.analysis.confidence_review import effective_confidence
|
||||
from viano_bug_analysis.analysis.investigation_prompt import output_schema
|
||||
from viano_bug_analysis.analysis.working_brief import model_view
|
||||
from viano_bug_analysis.analysis import Investigator, InvestigationError, DiagnosisBuilder
|
||||
from viano_bug_analysis.budget import BudgetManager
|
||||
from viano_bug_analysis.case.contracts import validate
|
||||
from viano_bug_analysis.llm import LLMError, ProviderResponse
|
||||
from viano_bug_analysis.llm.diagnostics import sanitize, schema_feedback
|
||||
from viano_bug_analysis.package import PackageReader
|
||||
from viano_bug_analysis.tools import InvestigationToolRouter
|
||||
from test_investigation_consolidation import populated
|
||||
from test_working_brief import brief, service_for
|
||||
from test_case_builder import case_setup
|
||||
from test_case_interpreter import gateway
|
||||
from test_investigation_tools import tool_case
|
||||
from test_investigator import LoopAdapter, CASE, run, state
|
||||
from test_project_research import rows
|
||||
from test_diagnosis_builder import FinalAdapter, build
|
||||
|
||||
|
||||
def context():
|
||||
return {'prompt_version':'investigation_project_v14',
|
||||
'evidence':{'E000001':{},'E000002':{}},
|
||||
'previous_result':{'record':{'id':'E000002'}},'working_brief':{'brief':brief()}}
|
||||
|
||||
|
||||
def reply():
|
||||
b=brief();b['format_version']=2;b.pop('changes');b['retired']=[]
|
||||
return {'assessment':{'semantic_score':90,'reason':'Useful counterevidence'},
|
||||
'update':{'confidence':'strongly_supported','resolve_questions':[],
|
||||
'add_questions':[],'working_brief':b},
|
||||
'next':{'kind':'finish','reason':'Mechanism contradicted'}}
|
||||
|
||||
|
||||
def test_one_findings_set_derives_refs_direction_and_assessment_binding_without_mutation():
|
||||
c=context();raw=reply();before=copy.deepcopy(raw)
|
||||
d=normalize(c,raw)
|
||||
assert raw==before
|
||||
assert d['assessment']['evidence_refs']==['E000002']
|
||||
assert d['update']['evidence_refs']==['E000001','E000002']
|
||||
assert d['update']['causal_review']['effect']=='contradicts'
|
||||
assert effective_confidence(d['update'])=='weakened'
|
||||
assert d['assessment']['semantic_score']==90
|
||||
assert d['update']['working_brief']['format_version']==1
|
||||
validate(d['update']['working_brief'],'investigation-working-brief')
|
||||
|
||||
|
||||
def test_derived_union_keeps_all_128_refs_without_a_second_model_list_limit():
|
||||
c={'prompt_version':'investigation_project_v14','evidence':{f'E{i:06d}':{} for i in range(1,129)},'previous_result':None}
|
||||
r=reply();r['assessment']=None
|
||||
r['update']['working_brief']['findings']=[{
|
||||
'id':f'B{i+1:04d}','direction':'support','text':'Scoped finding.', 'scope':'Snapshot A',
|
||||
'evidence_refs':[f'E{j:06d}' for j in range(i*8+1,i*8+9)]} for i in range(16)]
|
||||
d=normalize(c,r)
|
||||
assert len(d['update']['causal_review']['support'])==128
|
||||
assert len(d['update']['causal_review']['reason'])<=600
|
||||
from importlib.resources import files
|
||||
schema=json.loads(files('viano_bug_analysis.schemas').joinpath('investigation-state-v1.schema.json').read_text())
|
||||
note={'kind':'assessment','text':'Controller-derived brief','llm_ref':'call.json',
|
||||
'causal_review':d['update']['causal_review']}
|
||||
assert Draft202012Validator(schema['properties']['hypotheses']['additionalProperties']['properties']['notes']['items']).is_valid(note)
|
||||
|
||||
|
||||
def test_text_scope_precision_and_added_refs_automatically_audited():
|
||||
r=reply();f=r['update']['working_brief']['findings'][0]
|
||||
f.update(text='The required hook exists in the inspected source.',scope='Commit A, not deployment.',evidence_refs=['E000001','E000002'])
|
||||
result=normalize(context(),r)
|
||||
audit=result['brief_change_audit'][0]
|
||||
assert not audit['requires_reason']
|
||||
assert set(audit['fields'])=={'text','scope','evidence_refs'}
|
||||
assert result['update']['working_brief']['changes'][0]['reason'].startswith('Controller')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change',[
|
||||
lambda f:f.update(direction='neutral'),lambda f:f.update(evidence_refs=['E000002'])])
|
||||
def test_direction_change_or_lost_counterevidence_ref_requires_explicit_reason(change):
|
||||
r=reply();f=r['update']['working_brief']['findings'][0];change(f)
|
||||
with pytest.raises(InvestigationError,match='INVESTIGATION_BRIEF_CHANGE') as caught:
|
||||
normalize(context(),r)
|
||||
assert caught.value.feedback['ids']==['B0001']
|
||||
f['revision_reason']='A newer observation changes the applicable scope.'
|
||||
assert normalize(context(),r)['brief_change_audit'][0]['requires_reason']
|
||||
|
||||
|
||||
def test_removed_counterevidence_requires_retirement_and_keeps_old_references():
|
||||
r=reply();b=r['update']['working_brief'];b['findings']=[]
|
||||
with pytest.raises(InvestigationError,match='INVESTIGATION_BRIEF_CHANGE'):
|
||||
normalize(context(),r)
|
||||
b['retired']=[{'id':'B0001','reason':'Observation belongs to an obsolete deployment.'}]
|
||||
d=normalize(context(),r)
|
||||
assert d['update']['working_brief']['changes'][0]['evidence_refs']==['E000001']
|
||||
assert d['update']['causal_review']['effect']=='neutral'
|
||||
assert effective_confidence(d['update'])=='plausible'
|
||||
|
||||
|
||||
def test_unknown_reference_has_concrete_safe_feedback():
|
||||
r=reply();r['update']['working_brief']['findings'][0]['evidence_refs']=['E999999']
|
||||
with pytest.raises(InvestigationError) as caught:normalize(context(),r)
|
||||
assert caught.value.feedback=={'field':'update.working_brief.findings','ids':['E999999'],
|
||||
'requirement':'known_evidence_ids'}
|
||||
|
||||
|
||||
def test_both_sides_produce_mixed_effect_and_cannot_force_high_confidence():
|
||||
r=reply();f=copy.deepcopy(r['update']['working_brief']['findings'][0]);f.update(id='B0002',direction='support')
|
||||
r['update']['working_brief']['findings'].append(f)
|
||||
d=normalize(context(),r)
|
||||
assert d['update']['causal_review']['effect']=='mixed'
|
||||
assert effective_confidence(d['update'])=='plausible'
|
||||
assert d['update']['evidence_refs']==['E000001','E000002']
|
||||
|
||||
|
||||
def test_wire_does_not_accept_redundant_fields_and_old_contract_is_frozen():
|
||||
c=populated();c['prompt_version']='investigation_project_v14'
|
||||
schema=output_schema(c);r=reply()
|
||||
assert Draft202012Validator(schema).is_valid(r)
|
||||
r['update']['evidence_refs']=['E000001']
|
||||
assert not Draft202012Validator(schema).is_valid(r)
|
||||
old=output_schema(dict(c,prompt_version='investigation_project_v13'))['properties']['update']
|
||||
assert 'evidence_refs' in old['required'] and 'causal_review' in old['required']
|
||||
|
||||
|
||||
def test_v14_model_view_keeps_all_old_foreign_and_fresh_evidence_payloads():
|
||||
c=populated()
|
||||
for h in c['hypotheses'].values():h['disposition']='active'
|
||||
c['prompt_version']='investigation_project_v13';old=model_view(c)
|
||||
c['prompt_version']='investigation_project_v14';new=model_view(c)
|
||||
for eid in old['evidence']:
|
||||
assert new['evidence'][eid]['content']==old['evidence'][eid]['content']
|
||||
for key in ('shared_sources','shared_results','sources','interpretation'):
|
||||
assert new.get(key)==old.get(key)
|
||||
assert new['previous_result']==old['previous_result']
|
||||
|
||||
|
||||
def test_schema_feedback_never_echoes_response_values_or_unknown_property_names():
|
||||
schema={'type':'object','properties':{'update':{'type':'object',
|
||||
'properties':{'confidence':{'type':'integer'}},'required':['confidence']}}}
|
||||
err=next(Draft202012Validator(schema).iter_errors({'update':{}}))
|
||||
assert schema_feedback(err)=={'validation_path':'/update','validation_fields':['confidence']}
|
||||
assert sanitize({'validation_path':'/update/secret_key_content','validation_fields':['secret_value','confidence']})=={'validation_fields':['confidence']}
|
||||
|
||||
|
||||
def test_schema_feedback_selects_matching_action_instead_of_wrong_oneof_branch():
|
||||
schema={'type':'object','properties':{'next':{'oneOf':[
|
||||
{'type':'object','properties':{'kind':{'const':'finish'},'reason':{'type':'string'}},'required':['kind','reason']},
|
||||
{'type':'object','properties':{'kind':{'const':'tool'},'arguments':{'type':'object',
|
||||
'properties':{'start_line':{'type':'integer'}},'required':['start_line']}},'required':['kind','arguments']}]}}}
|
||||
err=next(Draft202012Validator(schema).iter_errors({'next':{'kind':'tool','arguments':{}}}))
|
||||
info=schema_feedback(err)
|
||||
assert info=={'validation_path':'/next/arguments','validation_fields':['start_line']}
|
||||
|
||||
|
||||
def test_controller_rejection_details_reach_retry_before_any_budget_effect(tool_case):
|
||||
service=service_for(tool_case)
|
||||
def change(reply,c):
|
||||
reply['next']={'kind':'tool','operation':'project.query','arguments':rows()}
|
||||
if c['previous_result']:
|
||||
b=brief();b['format_version']=2;b.pop('changes');b['retired']=[]
|
||||
b['findings'][0]['evidence_refs']=['E999999'];reply['update']['working_brief']=b
|
||||
loop=Investigator(tool_case[0],gateway(LoopAdapter(change)),router=InvestigationToolRouter(project_research=service))
|
||||
run(loop,steps=3);before=BudgetManager(tool_case[0]).snapshot(*CASE).state
|
||||
result=asyncio.run(loop.step(*CASE))
|
||||
assert result.status=='REJECTED' and BudgetManager(tool_case[0]).snapshot(*CASE).state==before
|
||||
saved=state(result)
|
||||
with PackageReader(result.package) as r:
|
||||
c=json.loads(r.read_bytes(saved['active']['context_ref']))
|
||||
assert c['previous_rejection_details']['ids']==['E999999']
|
||||
|
||||
|
||||
def test_gateway_schema_location_persists_in_retry_without_leaking_values(tool_case):
|
||||
service=service_for(tool_case)
|
||||
class Bad:
|
||||
async def generate(self,config,request,*,max_response_bytes):
|
||||
r=reply();r['update']['working_brief'].pop('retired')
|
||||
return ProviderResponse(r,'fixture',10,10)
|
||||
loop=Investigator(tool_case[0],gateway(Bad()),router=InvestigationToolRouter(project_research=service))
|
||||
result=asyncio.run(loop.step(*CASE));assert result.status=='REJECTED'
|
||||
with PackageReader(result.package) as reader:
|
||||
c=json.loads(reader.read_bytes(state(result)['active']['context_ref']))
|
||||
info=c['previous_rejection_details']
|
||||
assert info['validation_path']=='/update/working_brief' and info['validation_fields']==['retired']
|
||||
|
||||
|
||||
def test_v14_positive_early_review_reaches_independent_diagnosis_with_rest_budget(tool_case):
|
||||
service=service_for(tool_case)
|
||||
def change(reply,c):
|
||||
early=c['early_review']['request']
|
||||
if early and early['status']=='preparing':
|
||||
reply['next']={'kind':'defer','reason':'Settle pending evidence.'}
|
||||
elif c['selected_hypothesis']=='H1' and c['previous_result']:
|
||||
eid=c['previous_result']['evidence_id']
|
||||
other=next(e for e,v in c['evidence'].items() if v['record']['hypothesis_id']=='H2')
|
||||
reply['update'].update(confidence='strongly_supported',
|
||||
causal_review={'effect':'supports','support':[eid],'against':[],'reason':'Fixture causal defect.'})
|
||||
reply['next']={'kind':'request_diagnosis','reason':'Scoped defect with countercheck.','countercheck_refs':[other]}
|
||||
else:reply['next']={'kind':'tool','operation':'project.query','arguments':rows()}
|
||||
loop=Investigator(tool_case[0],gateway(LoopAdapter(change)),router=InvestigationToolRouter(project_research=service))
|
||||
done=run(loop,steps=10)
|
||||
assert done.status=='COMPLETED' and state(done)['early_stop']['status']=='requested'
|
||||
budget=BudgetManager(tool_case[0]).snapshot(*CASE).state
|
||||
assert all(a['remaining']>0 for a in budget['accounts'].values())
|
||||
adapter=FinalAdapter(True,lambda p,c:p.update(confidence='strongly_supported'))
|
||||
result=build(DiagnosisBuilder(tool_case[0],gateway(adapter)))
|
||||
assert result.status=='COMPLETED' and adapter.calls==1
|
||||
assert BudgetManager(tool_case[0]).snapshot(*CASE).state==budget
|
||||
|
||||
|
||||
def test_v14_can_finish_inconclusively_without_invented_cause(tool_case):
|
||||
service=service_for(tool_case)
|
||||
loop=Investigator(tool_case[0],gateway(LoopAdapter()),router=InvestigationToolRouter(project_research=service))
|
||||
done=run(loop,steps=10);assert done.status=='COMPLETED'
|
||||
result=build(DiagnosisBuilder(tool_case[0],gateway(FinalAdapter(False))))
|
||||
assert result.status=='INCONCLUSIVE'
|
||||
with PackageReader(result.package) as reader:
|
||||
assert reader.manifest['state']=='FAILED_TO_ANALYZE'
|
||||
assert reader.manifest['artifacts']['change_request'] is None
|
||||
@@ -204,7 +204,7 @@ def test_model_loop_uses_project_tools_and_compact_previous_result(tool_case, re
|
||||
if previous:
|
||||
assert context["evidence"][eid]["content"]["items"]
|
||||
assert "inputs" not in context["case"]
|
||||
assert request["prompt_version"] in ({"investigation_project_v9","investigation_project_v13"} if recall == "upgrade" else {"investigation_project_v9" if recall else "investigation_project_v13"})
|
||||
assert request["prompt_version"] in ({"investigation_project_v9","investigation_project_v14"} if recall == "upgrade" else {"investigation_project_v9" if recall else "investigation_project_v14"})
|
||||
reply={"assessment":{"semantic_score":50,"reason":"Scoped fact","evidence_refs":[eid]} if previous else None,
|
||||
"update":{"confidence":"plausible","evidence_refs":[eid] if previous else [],"resolve_questions":[],"add_questions":[],"note":"Query typed identity"},
|
||||
"next":{"kind":"finish","reason":"Check complete"} if previous else {"kind":"tool","operation":"project.query","arguments":rows()}}
|
||||
@@ -216,12 +216,16 @@ def test_model_loop_uses_project_tools_and_compact_previous_result(tool_case, re
|
||||
assert context['evidence'][recalled]['content']['items']
|
||||
reply['update']['evidence_refs'] = [recalled]
|
||||
reply['next'] = {'kind':'finish','reason':'Recalled exact evidence; uncertainty remains'}
|
||||
if request['prompt_version']=='investigation_project_v13':
|
||||
if request['prompt_version']=='investigation_project_v14':
|
||||
reply['update']['causal_review']={'effect':'neutral','support':[],'against':[],
|
||||
'reason':'Scoped observations are not independent evidence of a defect.'}
|
||||
if request['prompt_version']=='investigation_project_v13':
|
||||
if request['prompt_version']=='investigation_project_v14':
|
||||
reply['update']['working_brief']={'format_version':1,'findings':[],
|
||||
'uncertainties':['Scoped observations only.'],'next_question':'Check remaining uncertainty.','changes':[]}
|
||||
if request['prompt_version']=='investigation_project_v14':
|
||||
b=reply['update']['working_brief'];b['format_version']=2;b.pop('changes');b['retired']=[]
|
||||
for k in ('evidence_refs','causal_review','note'):reply['update'].pop(k,None)
|
||||
if reply['assessment']:reply['assessment'].pop('evidence_refs',None)
|
||||
return ProviderResponse(reply,"fixture",100,10)
|
||||
adapter=Adapter()
|
||||
loop=Investigator(store,gateway(adapter),router=InvestigationToolRouter(project_research=service))
|
||||
|
||||
@@ -144,12 +144,12 @@ def test_instruction_only_compatibility_mode_preserves_previous_snapshot_and_wir
|
||||
assert 'stable_case_context' not in json.loads(prefix[0]['parts'][0]['text'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('version',['investigation_project_v11','investigation_project_v12','investigation_project_v13'])
|
||||
@pytest.mark.parametrize('version',['investigation_project_v11','investigation_project_v12','investigation_project_v13','investigation_project_v14'])
|
||||
def test_v11_aliases_and_cached_case_do_not_shift_with_new_research(version):
|
||||
from test_investigation_consolidation import populated, capture
|
||||
from viano_bug_analysis.analysis.investigation_prompt import build_prompt
|
||||
c=populated();c['format']='viano-investigation-context';c['prompt_version']=version
|
||||
if version=='investigation_project_v13':
|
||||
if version in {'investigation_project_v13','investigation_project_v14'}:
|
||||
for h in c['hypotheses'].values():h['disposition']='active'
|
||||
c['budget']['accounts']['H2']=copy.deepcopy(c['budget']['accounts']['H1'])
|
||||
c['interpretation']={'summary':'Preserve the interpretation'}
|
||||
@@ -181,13 +181,13 @@ def test_v11_short_identifier_aliases_are_stable_and_reversible():
|
||||
assert compact_context(first)['sources']['path'].startswith('P0001:')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('version',['investigation_project_v10','investigation_project_v12','investigation_project_v13'])
|
||||
@pytest.mark.parametrize('version',['investigation_project_v10','investigation_project_v12','investigation_project_v13','investigation_project_v14'])
|
||||
def test_offline_measurement_matches_saved_context_without_provider_calls(make_package,monkeypatch,version):
|
||||
from test_investigation_consolidation import populated
|
||||
from viano_bug_analysis.analysis.investigation_prompt import build_prompt
|
||||
from viano_bug_analysis.llm.cache_measurement import measure_package
|
||||
c=populated();c['format']='viano-investigation-context';c['prompt_version']=version
|
||||
if version=='investigation_project_v13':
|
||||
if version in {'investigation_project_v13','investigation_project_v14'}:
|
||||
for h in c['hypotheses'].values():h['disposition']='active'
|
||||
c['budget']['accounts']['H2']=copy.deepcopy(c['budget']['accounts']['H1'])
|
||||
c['interpretation']={'summary':'Original interpretation'}
|
||||
@@ -200,7 +200,7 @@ def test_offline_measurement_matches_saved_context_without_provider_calls(make_p
|
||||
report=measure_package(path)
|
||||
assert report['requests']==1 and report['unmatched_requests']==0
|
||||
assert report['unique_prefixes']==1 and report['all_exactly_reconstructed']
|
||||
assert report['samples'][0]['rebuilt_prompt']==(version if version in {'investigation_project_v12','investigation_project_v13'} else 'investigation_project_v11')
|
||||
assert report['samples'][0]['rebuilt_prompt']==(version if version in {'investigation_project_v12','investigation_project_v13','investigation_project_v14'} else 'investigation_project_v11')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure',[None,400,404])
|
||||
|
||||
Reference in new issue
Block a user