156 lines
8.1 KiB
Python
156 lines
8.1 KiB
Python
"""Shared resumable analysis dispatch used by trials and the service worker."""
|
|
import os
|
|
from .analysis import (CaseInterpreter, HypothesisBuilder, HypothesisConfig, Investigator,
|
|
DiagnosisBuilder, ChangeRequestBuilder, OutputValidator)
|
|
from .analysis.correction import CorrectionConfig
|
|
from .case import CaseBuilder
|
|
from .lifecycle import State
|
|
from .llm import GatewayConfig, LLMGateway
|
|
from .llm.gateway import PRIVACY
|
|
from .package import PackageReader
|
|
|
|
class TrialError(Exception):
|
|
def __init__(self, code):
|
|
self.code = code
|
|
super().__init__(code)
|
|
|
|
|
|
def preflight(path):
|
|
"""Validate transport configuration and credentials without sending requests."""
|
|
config = GatewayConfig.load(path)
|
|
gateway = LLMGateway(config)
|
|
eligible = [p for p in config.providers
|
|
if p.enabled and p.privacy in PRIVACY[config.privacy_profile]]
|
|
if not eligible:
|
|
raise TrialError("LLM_PRIVACY_NO_PROVIDER")
|
|
for provider in eligible:
|
|
if provider.api_key_env:
|
|
value = os.environ.get(provider.api_key_env)
|
|
if not value or any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
raise TrialError("LLM_CREDENTIALS")
|
|
return gateway
|
|
|
|
|
|
def describe(path, status, *, code=None, exported=None, retry_not_before=None):
|
|
with PackageReader(path) as reader:
|
|
m = reader.manifest
|
|
return {"status": status, "code": code, "package": str(path),
|
|
"case_id": m["case_id"], "package_id": m["package_id"],
|
|
"package_generation": m["package_generation"],
|
|
"analysis_revision": m["analysis_revision"], "state": m["state"],
|
|
"routing": m["routing"], "exported": str(exported) if exported else None,
|
|
"retry_not_before": retry_not_before}
|
|
|
|
|
|
async def drive(store, gateway, case_id, package_id, *, max_dispatches=100,
|
|
attempt_id="default", policies=None, progress=None, stop_after=None):
|
|
if type(max_dispatches) is not int or max_dispatches <= 0:
|
|
raise TrialError("TRIAL_DISPATCH_LIMIT")
|
|
if stop_after not in {None, "case", "interpretation"}:
|
|
raise TrialError("TRIAL_STOP_PHASE")
|
|
policies = policies or {}
|
|
identity = (case_id, package_id)
|
|
correction = policies.get("correction", CorrectionConfig())
|
|
from .tools import InvestigationToolRouter
|
|
project_research = policies.get("project_research")
|
|
interpreter = CaseInterpreter(store, gateway, correction_config=correction, project_research=project_research,
|
|
joint_config=policies.get("analysis", HypothesisConfig()) if policies.get("planning_mode") == "joint" else None)
|
|
hypotheses = HypothesisBuilder(store, gateway, policies.get("analysis"), correction_config=correction)
|
|
loop = Investigator(store, gateway, policies.get("investigation"),
|
|
budget_config=policies.get("budget"),
|
|
router=InvestigationToolRouter(project_research=project_research))
|
|
diagnosis = DiagnosisBuilder(store, gateway, policies.get("diagnosis"))
|
|
request = ChangeRequestBuilder(store, gateway, policies.get("change_request"))
|
|
output = OutputValidator(store, gateway)
|
|
for dispatch in range(max_dispatches):
|
|
latest = store.latest(*identity)
|
|
if latest is None:
|
|
raise TrialError("CASE_NOT_FOUND")
|
|
with PackageReader(latest, store.limits) as reader:
|
|
state = reader.manifest["state"]
|
|
artifacts = dict(reader.manifest["artifacts"])
|
|
routing = reader.manifest["routing"]
|
|
# A reopened case retains prior artifacts. They are history, not
|
|
# completed stages of the new revision.
|
|
revision = reader.manifest['analysis_revision']
|
|
from .lifecycle.checkpoints import checkpoint_chain
|
|
chain = checkpoint_chain(reader)
|
|
for pointer, step in (
|
|
('case', f'case-builder:v1:revision:{revision}:complete'),
|
|
('case_interpretation', f'case-interpreter:v1:revision:{revision}:complete'),
|
|
('hypotheses', f'hypothesis-builder:v1:revision:{revision}:complete'),
|
|
('diagnosis', f'diagnosis:v1:{revision}:complete'),
|
|
('change_request', f'change-request:v1:{revision}:complete')):
|
|
if artifacts.get(pointer) and not any(cp['step_id'] == step for _, cp in chain):
|
|
# Contracts identify current products even when newer phase
|
|
# implementations use a different completion step name.
|
|
import json
|
|
doc = json.loads(reader.read_bytes(artifacts[pointer]))
|
|
artifact_identity = doc.get('case', doc)
|
|
if artifact_identity.get('analysis_revision') != revision:
|
|
artifacts[pointer] = None
|
|
if state == State.BLOCKED:
|
|
return describe(latest, "BLOCKED", code=routing["reason"],
|
|
retry_not_before=routing["retry_not_before"])
|
|
if phase_complete(latest, stop_after):
|
|
return describe(latest, "PHASE_COMPLETE")
|
|
if progress:
|
|
progress({"dispatch": dispatch + 1, "state": state})
|
|
if state in {State.OUTPUT_READY, State.FAILED_TO_ANALYZE, State.HANDED_OFF}:
|
|
result = await output.publish(*identity)
|
|
elif state == State.BUILDING_CASE and artifacts.get("case"):
|
|
result = await interpreter.interpret(*identity, attempt_id=attempt_id)
|
|
elif state in {State.VALIDATED, State.REOPENED, State.BUILDING_CASE}:
|
|
result = CaseBuilder(store, project_research=project_research).build(*identity)
|
|
elif state == State.INTERPRETING_CASE:
|
|
if artifacts.get("case_interpretation"):
|
|
result = await hypotheses.build(*identity, attempt_id=attempt_id)
|
|
else:
|
|
result = await interpreter.interpret(*identity, attempt_id=attempt_id)
|
|
elif state == State.GENERATING_HYPOTHESES:
|
|
if artifacts.get("hypotheses"):
|
|
result = await loop.step(*identity)
|
|
else:
|
|
result = await hypotheses.build(*identity, attempt_id=attempt_id)
|
|
elif state == State.INVESTIGATING_HYPOTHESES:
|
|
result = await loop.step(*identity)
|
|
elif state == State.BUILDING_DIAGNOSIS:
|
|
if not artifacts.get("diagnosis"):
|
|
result = await diagnosis.build(*identity)
|
|
elif not artifacts.get("change_request"):
|
|
result = await request.build(*identity)
|
|
else:
|
|
result = await output.publish(*identity)
|
|
else:
|
|
raise TrialError("TRIAL_PHASE")
|
|
status = getattr(result, "status", "COMPLETED")
|
|
if status == "INCONCLUSIVE":
|
|
result = await output.publish(*identity)
|
|
status = result.status
|
|
if status in {"BLOCKED", "RETRY_PENDING", "DEFERRED", "ERROR", "BUSY",
|
|
"OUTPUT_READY", "REVIEW", "HANDED_OFF"}:
|
|
return describe(result.package, status, code=getattr(result, "code", None),
|
|
exported=getattr(result, "exported", None),
|
|
retry_not_before=getattr(result, "retry_not_before", None))
|
|
latest = store.latest(*identity)
|
|
return describe(latest, "PHASE_COMPLETE" if phase_complete(latest, stop_after) else "YIELDED")
|
|
|
|
|
|
def phase_complete(path, stop_after):
|
|
if stop_after is None:
|
|
return False
|
|
from .case.contracts import read_json
|
|
with PackageReader(path) as reader:
|
|
manifest = reader.manifest
|
|
if stop_after == "case":
|
|
ref = manifest["artifacts"].get("case")
|
|
if manifest["state"] != State.BUILDING_CASE or not ref:
|
|
return False
|
|
doc = read_json(reader, ref, "case", 8 * 1024 * 1024)["case"]
|
|
else:
|
|
ref = manifest["artifacts"].get("case_interpretation")
|
|
if manifest["state"] != State.INTERPRETING_CASE or not ref:
|
|
return False
|
|
doc = read_json(reader, ref, "case-interpretation", 2 * 1024 * 1024)
|
|
return all(doc[field] == manifest[field] for field in ("case_id", "package_id", "analysis_revision"))
|