978052b5a2
Fix B of the pre-#59 semantic correction. The Silent Pass had only TWO effective states though the data
carries three: a `detected` mapping (a concrete artifact) AND a `partial` mapping (an indicative signal,
e.g. a CI pipeline -> secure-development-lifecycle) both flowed through capability_ids() and were fed to
the Advisor as already-present — so a weak indication silently removed a question, exactly the Welt-1/
Welt-2 transparency we want to keep.
Now three distinct states:
- detected -> reduces the delta immediately (auto_detected, not asked). [unchanged]
- partial -> raises assumption strength but does NOT replace the question (surfaced as `indications`,
the capability stays in the delta and is still asked).
- requirement-> describes a target, never the present state (already handled by Fix A's kind split).
Changes (data + thin wiring, no new architecture):
- SilentIntakeResult.capability_ids() returns only relationship==detected; new indicative_capability_ids()
returns the partial ones.
- advisor_start() gains indicative_capabilities (NOT fed into the profile) and surfaces result.indications
= indicative ∩ required − auto_detected.
- AdvisorResult / AdvisorResponse gain `indications` (additive, contract-safe); the service passes the
indicative ids through.
Tests: a partial CI signal is indicative-not-detected and does NOT shrink the delta; end-to-end it appears
in `indications`, not `auto_detected`, and the gap is still asked. 28 onboarding tests pass, mypy --strict
clean on the onboarding modules, demo runs, check-loc 0. Runtime effect -> deploy + smoke.
160 lines
7.7 KiB
Python
160 lines
7.7 KiB
Python
"""Smart Onboarding Advisor — orchestration over the existing engines (the onboarding runtime step).
|
|
|
|
The point of the whole platform, made usable: the user types company + products + certifications +
|
|
target, and the system does the rest — no sales interpretation, no regulation picking. This is an
|
|
ORCHESTRATOR, not a new engine: it wires Company 2A (Evidence -> Capability), RS-005 (Capability ->
|
|
Delta), optimization (Delta -> Roadmap) and completeness into one onboarding flow.
|
|
|
|
Three principles it must honour (acceptance criteria):
|
|
- Multi-cert works; a profile is built from ALL certificates.
|
|
- relevance(evidence, target): ISO 14001 is NOT falsely relevant to the CRA; ISO 27001/TISAX REDUCE
|
|
questions but satisfy NOTHING automatically (Welt-1 -> verification_required).
|
|
- Only the NEXT BEST questions (<= 5), each explaining WHY; every answer updates the profile.
|
|
|
|
Certificate -> probable-capability hypotheses and the target's required capabilities are INJECTED (the
|
|
hypotheses are curated knowledge, not in this code). No corpus loaded here. Python 3.9 compatible.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Dict, List, Optional, Sequence
|
|
|
|
from ..company import (
|
|
CapabilityMappingEntry,
|
|
Certification,
|
|
CompanyCapabilityProfile,
|
|
CompanyContext,
|
|
build_company_profile,
|
|
)
|
|
from ..completeness import assess_completeness
|
|
from ..optimization import roadmap_from_delta
|
|
from ..reasoning.enums import Confidence
|
|
from ..transition_reasoning import (
|
|
CoverageStatus,
|
|
TargetRequirement,
|
|
TransitionContext,
|
|
TransitionGoal,
|
|
assess_transition,
|
|
)
|
|
from .schemas import (
|
|
AdvisorMeasure,
|
|
AdvisorQuestion,
|
|
AdvisorResult,
|
|
InferredAssumption,
|
|
OnboardingInput,
|
|
RejectedAssumption,
|
|
)
|
|
|
|
_GAIN = {"high": 3, "medium": 2, "low": 1}
|
|
_RISK = {"high": 2, "medium": 1, "low": 0}
|
|
|
|
|
|
def _profile(
|
|
inp: OnboardingInput, cert_hypotheses: Dict[str, List[str]],
|
|
detected: Optional[Sequence[str]] = None,
|
|
) -> CompanyCapabilityProfile:
|
|
cmap = {
|
|
cert: CapabilityMappingEntry(capability_ids=list(caps), confidence=Confidence.MEDIUM)
|
|
for cert, caps in cert_hypotheses.items()
|
|
if cert in inp.certifications and caps
|
|
}
|
|
certs = [Certification(certification_id=c) for c in cmap]
|
|
if detected: # Silent Pass: concrete findings -> HIGH confidence
|
|
cmap["__detected__"] = CapabilityMappingEntry(
|
|
capability_ids=list(dict.fromkeys(detected)), confidence=Confidence.HIGH)
|
|
certs.append(Certification(certification_id="__detected__"))
|
|
return build_company_profile(CompanyContext(company_id=inp.company or "company", certifications=certs), cmap)
|
|
|
|
|
|
def advisor_start(
|
|
inp: OnboardingInput,
|
|
cert_hypotheses: Dict[str, List[str]],
|
|
target_requirements: Sequence[TargetRequirement],
|
|
target_id: str = "target",
|
|
covers_targets: Optional[Dict[str, List[str]]] = None,
|
|
corpus_status: Optional[Dict[str, str]] = None,
|
|
uncertain: Optional[List[Dict[str, str]]] = None,
|
|
detected_capabilities: Optional[Sequence[str]] = None,
|
|
indicative_capabilities: Optional[Sequence[str]] = None,
|
|
) -> AdvisorResult:
|
|
"""Run the onboarding flow: (silent intake +) certs -> profile -> delta -> ranked questions + measures.
|
|
|
|
Pure orchestration; deterministic. `cert_hypotheses` (cert -> probable cap ids), `target_requirements`
|
|
and `detected_capabilities` (from the Silent Knowledge Pass) are INJECTED. Detected capabilities are
|
|
recognised WITHOUT asking -> they shrink the delta and remove questions.
|
|
"""
|
|
covers_targets = covers_targets or {}
|
|
required = {r.capability_id for r in target_requirements}
|
|
profile = _profile(inp, cert_hypotheses, detected_capabilities)
|
|
auto_detected = sorted(set(detected_capabilities or []) & required)
|
|
# partial/indicative signals raise assumption strength but are NOT fed into the profile -> the gap
|
|
# stays open and is still asked. Surface only those still relevant and NOT already auto-detected.
|
|
indications = sorted((set(indicative_capabilities or []) & required) - set(auto_detected))
|
|
assess = assess_transition(
|
|
TransitionContext(company_id=inp.company or "company", target=TransitionGoal(target_id=target_id)),
|
|
list(target_requirements), profile)
|
|
|
|
# inferred (Welt-1): per cert, the caps it probably provides that are RELEVANT to this target
|
|
inferred: List[InferredAssumption] = []
|
|
rejected: List[RejectedAssumption] = []
|
|
for cert in inp.certifications:
|
|
caps = set(cert_hypotheses.get(cert, []))
|
|
relevant = sorted(caps & required)
|
|
if relevant:
|
|
inferred.append(InferredAssumption(
|
|
certification=cert, capabilities=relevant,
|
|
statement="%s legt %d relevante Fähigkeit(en) nahe — Verifikation erforderlich, nicht automatisch erfüllt"
|
|
% (cert, len(relevant))))
|
|
elif caps:
|
|
rejected.append(RejectedAssumption(
|
|
certification=cert,
|
|
statement="%s ist für dieses Ziel nicht relevant" % cert,
|
|
reason="relevance(evidence, target) = 0 — keine geforderte Fähigkeit abgedeckt"))
|
|
|
|
# next best questions (<=5): re-rank the RS-005 requests by info gain + leverage + risk + evidence-gap
|
|
known_ev = set(inp.known_evidence)
|
|
scored = []
|
|
for q in assess.question_requests:
|
|
lev = len(covers_targets.get(q.capability_id, []))
|
|
ev_missing = 1 if (q.expected_evidence and not (set(q.expected_evidence) & known_ev)) else 0
|
|
score = _GAIN.get(q.information_gain.value, 1) + lev + _RISK.get(q.priority.value, 0) + ev_missing
|
|
scored.append((score, q))
|
|
scored.sort(key=lambda x: (-x[0], x[1].capability_id))
|
|
next_q = [
|
|
AdvisorQuestion(capability_id=q.capability_id, question_intent=q.question_intent, why=q.reason,
|
|
information_value=float(s), priority=q.priority.value)
|
|
for s, q in scored[:5]
|
|
]
|
|
|
|
delta = sorted({c.capability_id for c in assess.coverage if c.status == CoverageStatus.MISSING})
|
|
plan = roadmap_from_delta(assess, {c: covers_targets.get(c, []) for c in delta})
|
|
measures = [AdvisorMeasure(capability_id=m.capability_id, leverage=m.leverage, closes=m.covers)
|
|
for m in plan.ranked_measures[:5]]
|
|
evidence = sorted({e for q in assess.question_requests for e in q.expected_evidence})
|
|
|
|
applicable = list(inp.target) or [target_id]
|
|
rep = assess_completeness(applicable, corpus_status or {}, uncertain=uncertain or [])
|
|
unsupported = [e.subject for e in rep.exclusions]
|
|
|
|
probably = [c for c in assess.summary.probably_covered if c not in set(auto_detected)]
|
|
return AdvisorResult(
|
|
inferred_assumptions=inferred, rejected_assumptions=rejected, auto_detected=auto_detected,
|
|
indications=indications,
|
|
next_best_questions=next_q, capability_delta=delta, top_measures=measures,
|
|
evidence_requests=evidence, unsupported_domains=unsupported,
|
|
completeness_summary=rep.completeness_summary,
|
|
headline="%d Anforderungen erkannt · %d automatisch erkannt (Intake) · %d wahrscheinlich (Zertifikate) · %d zu klären"
|
|
% (len(assess.coverage), len(auto_detected), len(probably), len(next_q)))
|
|
|
|
|
|
def apply_answer(known_capabilities: Sequence[str], capability_id: str, answer: str) -> List[str]:
|
|
"""Update the known-capability set from one answer. `answer` in {confirmed, rejected, unknown}.
|
|
|
|
A confirmed answer adds the capability to the known set (shrinking the delta on the next run);
|
|
rejected/unknown leave it open. This is how every answer updates the profile (criterion 6).
|
|
"""
|
|
known = list(dict.fromkeys(known_capabilities))
|
|
if answer == "confirmed" and capability_id not in known:
|
|
known.append(capability_id)
|
|
return known
|