Skip to content

symfonic.capabilities.memory.phases.drafts

drafts

ProceduralExtractor protocol + ProceduralDraft dataclass.

Moved here from symfonic.core.learning.procedural_extractor, which now imports it back. This is the seam phase 12 consumes -- what a deployment hands it, and what it writes -- and the phase is on a roster the kernel composes. The concrete model-backed adapter stays in the legacy package: it is an integration, and nothing on a roster imports it.

v7.6 (adopter Ask 9) — opt-in surface for the LLM-backed Phase 12 extractor. Mirrors the v7.2 EntityExtractor pluggability shape:

  • the regex-based extractor in :mod:symfonic.core.learning.phases_procedural_promotion is the default (configured at v6.1) and produces flat verb-object skills;
  • the LLM-backed extractor in :mod:symfonic.core.learning.procedural_extractor_llm is opt-in via FrameworkConfig.phase_12_use_llm_extractor=True and produces structured :class:ProceduralDraft records that distinguish action / preflight / recovery / guardrail rules.

The protocol is intentionally minimal -- the dispatch site in :func:promote_episodic_to_procedural only needs to call extract and turn the returned drafts into :class:~symfonic.memory.models.entry.MemoryEntry records via the shared :func:_build_skill_entry_from_draft helper.

Design reference: .claude/docs/2026-05-28-adopter-llm-procedural-extractor-eval.md §8.

ProceduralKind module-attribute

ProceduralKind = Literal['action', 'preflight', 'recovery', 'guardrail']

Allowed values for ProceduralDraft.kind.

The four-way split tracks the failure modes adopter identified in their deep-dive (symfonic-core-ask-llm-procedural-extractor.md):

  • action — repeated tool usage that should become a learned habit.
  • preflight — pre-flight check that prevents downstream failure ("before X, call Y"). The most load-bearing kind for v7.6 since the regex extractor structurally cannot represent it.
  • recovery — error-recovery rule ("after E, call R").
  • guardrail — negative rule ("never X — use Y instead").

VALID_PROCEDURAL_KINDS module-attribute

VALID_PROCEDURAL_KINDS: frozenset[str] = frozenset({'action', 'preflight', 'recovery', 'guardrail'})

Runtime mirror of :data:ProceduralKind for response-shape validation.

The Literal type is gone at runtime so the LLM-response parser uses this frozenset to clamp unknown kinds back to "action" (the most benign default — a noisy action draft surfaces to human review, a fabricated preflight rule could hide a real precondition gap).

ProceduralDraft dataclass

ProceduralDraft(kind: ProceduralKind, trigger: str, action: str, rationale: str, confidence: float = 0.0, precondition: str | list[str] | None = None, source_episode_ids: list[str] = list())

Structured draft produced by a :class:ProceduralExtractor.

Frozen so the dispatch site can pass instances around without worrying about downstream mutation. Defaults match the LLM prompt's nullable / optional fields so a minimal response can still construct a valid draft (only kind, trigger, action, and rationale are strictly required).

precondition shape (v7.6.3 -- adopter multi-precondition ask):

  • None -- no pre-flight gate.
  • str -- single pre-flight rule (v7.6.0 byte-identical shape; preserved so consumers that isinstance(p, str) still match the common case).
  • list[str] -- ordered multi-pre-flight rule. Mirrors the v7.6.2 storage widening on :meth:~symfonic.memory.layers.procedural.layer.ProceduralLayer.seed_authored_skill, so LLM-extracted drafts and hand-authored skills share one storage shape. The v7.5.1 :class:~symfonic.memory.layers.procedural.router.CapabilityRouter renders one indented PRE-FLIGHT: line per entry in supplied order.

ProceduralExtractor

Bases: Protocol

Pluggable Phase 12 extractor interface.

Implementations MUST be best-effort: a malformed response, a missing field, or an exception during model invocation must yield [] rather than raise. The dispatch site logs the empty result via the standard Phase 12 telemetry line so consumers can tell "extractor found nothing" from "extractor crashed" via the cost_usd / llm_calls fields.

draft_to_memory_entry

draft_to_memory_entry(draft: ProceduralDraft, scope: TenantScope) -> MemoryEntry

Convert a :class:ProceduralDraft to a procedural MemoryEntry.

Moved here with the draft it reads. Phase 12's two extractors -- the regex one and the model-backed one -- write through one store_skill contract, and the phase is on a roster the kernel composes, so the conversion cannot live in the legacy package the concrete LLM adapter stays in.

Used by the Phase 12 dispatch site in :func:promote_episodic_to_procedural so the LLM and regex paths share the same store_skill write contract. The draft's structured fields land in the entry metadata under stable keys so the v7.5 query_skills projection surfaces them:

  • precondition -> properties['precondition'] (v7.5)
  • source -> "phase_12_llm_extractor" (distinct from the regex extractor's "phase_12_promotion" so SRE telemetry can split adoption).
  • kind / confidence / rationale / trigger / source_episode_ids are first-class properties for v7.7+ router enrichment.

The label field follows the v6.1 "Auto-learned: <name>" convention so downstream label-prefix grouping (e.g. SemanticMerge similarity, /procedures listing UI) keeps working unchanged.

Source code in src/symfonic/capabilities/memory/phases/drafts.py
def draft_to_memory_entry(
    draft: ProceduralDraft,
    scope: TenantScope,
) -> MemoryEntry:
    """Convert a :class:`ProceduralDraft` to a procedural ``MemoryEntry``.

    Moved here with the draft it reads. Phase 12's two extractors -- the regex
    one and the model-backed one -- write through one ``store_skill`` contract,
    and the phase is on a roster the kernel composes, so the conversion cannot
    live in the legacy package the concrete LLM adapter stays in.

    Used by the Phase 12 dispatch site in
    :func:`promote_episodic_to_procedural` so the LLM and regex paths
    share the same ``store_skill`` write contract.  The draft's
    structured fields land in the entry metadata under stable keys so
    the v7.5 ``query_skills`` projection surfaces them:

    * ``precondition`` -> ``properties['precondition']`` (v7.5)
    * ``source``       -> ``"phase_12_llm_extractor"`` (distinct from
      the regex extractor's ``"phase_12_promotion"`` so SRE telemetry
      can split adoption).
    * ``kind`` / ``confidence`` / ``rationale`` / ``trigger`` /
      ``source_episode_ids`` are first-class properties for v7.7+
      router enrichment.

    The ``label`` field follows the v6.1 ``"Auto-learned: <name>"``
    convention so downstream label-prefix grouping (e.g. SemanticMerge
    similarity, /procedures listing UI) keeps working unchanged.
    """
    # Compose a content string that the SemanticMerge janitor and
    # human reviewers can scan at a glance.  The trigger is the
    # one-line gate; we add the action + rationale so the content
    # survives Phase 12 telemetry truncation in audit logs.
    content = (
        f"{draft.trigger}\nAction: {draft.action}\n"
        f"Rationale: {draft.rationale}"
    )
    label = f"Auto-learned ({draft.kind}): {draft.trigger[:80]}"[:100]
    return MemoryEntry(
        layer=MemoryLayer.PROCEDURAL,
        tenant_id=scope.tenant_id,
        content=content,
        importance=5.0,
        metadata={
            "label": label,
            "steps": [draft.action],
            "context": draft.rationale,
            "source": "phase_12_llm_extractor",
            "kind": draft.kind,
            "trigger": draft.trigger,
            "rationale": draft.rationale,
            "confidence": draft.confidence,
            "source_episode_ids": list(draft.source_episode_ids),
            **(
                {"precondition": draft.precondition}
                if draft.precondition else {}
            ),
        },
    )