diff --git a/backend-compliance/compliance/api/onboarding_routes.py b/backend-compliance/compliance/api/onboarding_routes.py index f8ef92a8..c0a53bfa 100644 --- a/backend-compliance/compliance/api/onboarding_routes.py +++ b/backend-compliance/compliance/api/onboarding_routes.py @@ -41,6 +41,7 @@ class AdvisorResponse(BaseModel): silent_intake_summary: str = "" headline: str = "" auto_detected: List[str] = Field(default_factory=list) + indications: List[str] = Field(default_factory=list) # partial signal: raises strength, still asked inferred_assumptions: List[InferredAssumption] = Field(default_factory=list) rejected_assumptions: List[RejectedAssumption] = Field(default_factory=list) top_5_questions: List[AdvisorQuestion] = Field(default_factory=list) @@ -66,6 +67,7 @@ def advisor_start_endpoint(req: OnboardingAdvisorRequest) -> AdvisorResponse: products=req.products, markets=req.markets, industry=req.industry or "") return AdvisorResponse( silent_intake_summary=si_summary, headline=result.headline, auto_detected=result.auto_detected, + indications=result.indications, inferred_assumptions=result.inferred_assumptions, rejected_assumptions=result.rejected_assumptions, top_5_questions=result.next_best_questions, capability_delta=result.capability_delta, top_measures=result.top_measures, evidence_requests=result.evidence_requests, diff --git a/backend-compliance/compliance/onboarding/engine.py b/backend-compliance/compliance/onboarding/engine.py index 3af85f0e..a226287c 100644 --- a/backend-compliance/compliance/onboarding/engine.py +++ b/backend-compliance/compliance/onboarding/engine.py @@ -75,6 +75,7 @@ def advisor_start( 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. @@ -86,6 +87,9 @@ def advisor_start( 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) @@ -135,6 +139,7 @@ def advisor_start( 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, diff --git a/backend-compliance/compliance/onboarding/schemas.py b/backend-compliance/compliance/onboarding/schemas.py index 0cf23545..78c6bc3f 100644 --- a/backend-compliance/compliance/onboarding/schemas.py +++ b/backend-compliance/compliance/onboarding/schemas.py @@ -53,7 +53,8 @@ class AdvisorMeasure(BaseModel): class AdvisorResult(BaseModel): inferred_assumptions: List[InferredAssumption] = Field(default_factory=list) rejected_assumptions: List[RejectedAssumption] = Field(default_factory=list) - auto_detected: List[str] = Field(default_factory=list) # Silent Pass: recognised w/o asking + auto_detected: List[str] = Field(default_factory=list) # detected (concrete artifact): recognised w/o asking + indications: List[str] = Field(default_factory=list) # partial signal: raises assumption strength, STILL asked next_best_questions: List[AdvisorQuestion] = Field(default_factory=list) # max 5 capability_delta: List[str] = Field(default_factory=list) top_measures: List[AdvisorMeasure] = Field(default_factory=list) diff --git a/backend-compliance/compliance/onboarding/silent_intake.py b/backend-compliance/compliance/onboarding/silent_intake.py index d5cda1fa..8d91338e 100644 --- a/backend-compliance/compliance/onboarding/silent_intake.py +++ b/backend-compliance/compliance/onboarding/silent_intake.py @@ -66,10 +66,15 @@ class SilentIntakeResult(BaseModel): summary: str = "" def capability_ids(self) -> List[str]: - """The detected capability ids — fed into the Advisor as already-present (delta-reducing). + """The DETECTED capability ids (relationship == detected) — fed into the Advisor as already-present + (delta-reducing, not asked). ONLY observation-kind signals reach here (requirements never become a + present capability); a merely PARTIAL/indicative signal does NOT (see indicative_capability_ids).""" + return sorted({d.capability for d in self.detected_capabilities if d.relationship == "detected"}) - ONLY observation-kind signals reach here (requirements never become a present capability).""" - return sorted({d.capability for d in self.detected_capabilities}) + def indicative_capability_ids(self) -> List[str]: + """Capabilities backed only by a PARTIAL/indicative signal — they raise assumption strength but do + NOT replace a question (the gap stays open and is still asked, just with an indication shown).""" + return sorted({d.capability for d in self.detected_capabilities if d.relationship != "detected"}) def silent_intake( diff --git a/backend-compliance/compliance/services/onboarding_service.py b/backend-compliance/compliance/services/onboarding_service.py index c57c4f0e..bfe9138f 100644 --- a/backend-compliance/compliance/services/onboarding_service.py +++ b/backend-compliance/compliance/services/onboarding_service.py @@ -76,5 +76,6 @@ def run_advisor( known_evidence=list(known_evidence), target=[target]) result = advisor_start( inp, resolve_for_certifications(certifications, _HYP_LIB), reqs, target_id=target, - covers_targets=covers, corpus_status={target: "validated"}, detected_capabilities=si.capability_ids()) + covers_targets=covers, corpus_status={target: "validated"}, + detected_capabilities=si.capability_ids(), indicative_capabilities=si.indicative_capability_ids()) return result, si.summary diff --git a/backend-compliance/tests/test_onboarding_endpoint.py b/backend-compliance/tests/test_onboarding_endpoint.py index c50d1f5f..9d38c2bf 100644 --- a/backend-compliance/tests/test_onboarding_endpoint.py +++ b/backend-compliance/tests/test_onboarding_endpoint.py @@ -61,6 +61,18 @@ def test_requirement_signal_does_not_auto_detect_capability(): assert "sbom_creation" in asked or "sbom_creation" in d["capability_delta"] # still an open gap +def test_partial_signal_surfaces_as_indication_and_is_still_asked(): + # a PARTIAL observation (a CI pipeline) raises assumption strength but does NOT replace the question + body = dict(_BODY, scanner_findings=[{"signal_id": "github_actions_ci", "source_type": "repository"}]) + r = _client.post("/onboarding/advisor-start", json=body) + assert r.status_code == 200, r.text + d = r.json() + assert "secure_development_lifecycle" not in d["auto_detected"] # partial != detected + assert "secure_development_lifecycle" in d["indications"] # but its strength is shown + asked = {q["capability_id"] for q in d["top_5_questions"]} + assert "secure_development_lifecycle" in asked or "secure_development_lifecycle" in d["capability_delta"] + + def test_unknown_target_is_404(): body = dict(_BODY, target="NOPE") r = _client.post("/onboarding/advisor-start", json=body) diff --git a/backend-compliance/tests/test_silent_intake.py b/backend-compliance/tests/test_silent_intake.py index f0139e63..161776a9 100644 --- a/backend-compliance/tests/test_silent_intake.py +++ b/backend-compliance/tests/test_silent_intake.py @@ -77,3 +77,23 @@ def test_detected_capabilities_are_not_asked_again(): detected_capabilities=detected) asked = {q.capability_id for q in res.next_best_questions} assert "sbom_creation" not in asked and "sbom_creation" not in res.capability_delta + + +def test_partial_signal_is_indicative_not_detected(): + # a PARTIAL signal (CI present -> secure dev lifecycle) raises assumption strength but is NOT a + # detected capability: it must NOT shrink the delta the way a concrete artifact does. + res = silent_intake([IntakeSignal(source="repository", signal="github_actions_ci")], _MAP) + assert "secure_development_lifecycle" not in res.capability_ids() # not counted as present + assert res.indicative_capability_ids() == ["secure_development_lifecycle"] # surfaced as an indication + + +def test_partial_indication_does_not_remove_the_question(): + inp = OnboardingInput(company="x", certifications=["ISO27001"], target=["CRA"]) + hyp = resolve_for_certifications(inp.certifications, _LIB) + si = silent_intake([IntakeSignal(source="repository", signal="github_actions_ci")], _MAP) + res = advisor_start(inp, hyp, _REQ, target_id="CRA", corpus_status={"CRA": "validated"}, + detected_capabilities=si.capability_ids(), + indicative_capabilities=si.indicative_capability_ids()) + assert "secure_development_lifecycle" not in res.auto_detected # partial != detected + assert "secure_development_lifecycle" in res.indications # strength shown + assert "secure_development_lifecycle" in res.capability_delta # gap still open / asked