mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
142 lines
4.7 KiB
Python
142 lines
4.7 KiB
Python
"""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 (1–3 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 1–3 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
|