dograh/api/services/voice_prompting_guide/_base.py

143 lines
4.7 KiB
Python
Raw Permalink Normal View History

"""Schema for voice-prompting guidance atoms.
Each `VoicePromptingTopic` is one self-contained piece of advice (e.g.
turn-taking, persona lock, readback rules). The same atom is surfaced
to the LLM through several channels node `llm_hint`s, the
`get_voice_prompting_guide` tool, save-time lint tips, and the
`/audit_voice_prompts` reviewer without copying the body anywhere.
Everything else references a topic by `id` and quotes at most one line.
Stage lenses are short framings (13 lines) of how the same atom matters
during plan vs. create vs. review. They are NOT a second copy of the
content; they tell the agent where to point its attention at that stage.
`review_signals` are mechanical regex checks over prompt-field text
only safe to fire on every save. `audit_checks` are intent-level
questions that need LLM judgment and only run under the user-invoked
audit flow. The two are kept separate because conflating "prompt
literally ends with '?'" with "prompt instructs the agent to ask a
question" yields garbage tips.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
class Stage(str, Enum):
"""Authoring stages. Drives briefing assembly in the resolver."""
plan = "plan"
create = "create"
review = "review"
class StageLens(BaseModel):
"""A topic's framing for one stage. Either marked irrelevant, or
carries 13 lines of stage-specific guidance pointing at the atom's
full content."""
relevant: bool = False
lens: Optional[str] = None
model_config = ConfigDict(extra="forbid")
class ReviewSignal(BaseModel):
"""Mechanical detector — regex over literal prompt text.
Use only for surface-level issues (markdown in a voice prompt,
digits where spoken form is needed, persona missing from global).
Never for runtime behavior the prompt is *meant to produce* that
belongs in `audit_checks`.
"""
id: str
pattern: str = Field(
...,
description="Python regex applied to prompt-field text.",
)
quote: str = Field(
...,
description="One-line user-facing tip when the pattern matches.",
)
model_config = ConfigDict(extra="forbid")
class AuditCheck(BaseModel):
"""Intent-level check — requires LLM judgment via `/audit_voice_prompts`.
The judge agent answers `judge_question` yes/no against the prompt
being audited; a result that differs from `expected` is a finding.
"""
id: str
judge_question: str
expected: Literal["yes", "no"] = "yes"
quote: str
model_config = ConfigDict(extra="forbid")
class VoicePromptingTopic(BaseModel):
"""One atom of voice-prompting guidance.
`content` is the single source of truth. Lenses, llm_hints, signals,
and checks reference this atom by `id`; they do not duplicate the
content text.
"""
id: str
title: str
severity: Literal["low", "medium", "high"] = "medium"
applies_to_node_types: tuple[str, ...] = Field(default_factory=tuple)
stages: dict[Stage, StageLens] = Field(default_factory=dict)
content: str = Field(..., min_length=1)
review_signals: tuple[ReviewSignal, ...] = Field(default_factory=tuple)
audit_checks: tuple[AuditCheck, ...] = Field(default_factory=tuple)
cross_refs: tuple[str, ...] = Field(default_factory=tuple)
model_config = ConfigDict(extra="forbid")
def lens_for(self, stage: Stage) -> Optional[str]:
sl = self.stages.get(stage)
if sl is None or not sl.relevant:
return None
return sl.lens
def is_relevant_to(self, node_type: Optional[str]) -> bool:
if node_type is None:
return True
# An atom with no `applies_to_node_types` is treated as
# cross-cutting (relevant to every node type).
if not self.applies_to_node_types:
return True
return node_type in self.applies_to_node_types
def to_briefing_dict(self, stage: Stage) -> dict[str, Any]:
return {
"id": self.id,
"title": self.title,
"lens": self.lens_for(stage) or "",
}
def to_deep_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"id": self.id,
"title": self.title,
"severity": self.severity,
"content": self.content,
"stages_relevant": [
stage.value for stage, sl in self.stages.items() if sl.relevant
],
}
if self.applies_to_node_types:
out["applies_to_node_types"] = list(self.applies_to_node_types)
if self.cross_refs:
out["cross_refs"] = list(self.cross_refs)
return out