Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ repos:
args: [ --fix ]
- id: ruff-format

- repo: local
hooks:
- id: mypy
name: mypy
entry: uv run mypy
language: system
types: [ python ]
pass_filenames: false

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ Scribae is a CLI tool that transforms local Markdown notes into structured SEO c
## Quick Reference

```bash
uv sync --locked --all-extras --dev # Install dependencies
uv sync --locked --all-extras --dev # Required: install all dependencies including PyTorch
uv run scribae --help # Run CLI
uv run ruff check # Lint (auto-fix: --fix)
uv run mypy # Type check
uv run pytest # Run tests
```

**Important:** Always run tests, mypy, and ruff at the end of your task and fix any issues.
**Important:** The `--all-extras` flag is required for development (PyTorch needed for mypy). Always run tests, mypy, and ruff at the end of your task and fix any issues.

## Project Structure

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Scribae is a CLI tool that transforms local Markdown notes into structured SEO c
## Build & Development Commands

```bash
uv sync --locked --all-extras --dev # Install dependencies (includes PyTorch with CUDA)
uv sync --locked --all-extras --dev # Required: install all dependencies including PyTorch
uv run scribae --help # Run CLI
uv run ruff check # Lint (auto-fix: --fix)
uv run mypy # Type check
Expand All @@ -25,7 +25,7 @@ For a lighter install (~200MB vs ~2GB), use the CPU-only PyTorch index:
uv sync --locked --all-extras --dev --index pytorch-cpu
```

**Important:** Always run tests, mypy, and ruff at the end of your task and fix any issues.
**Important:** The `--all-extras` flag is required for development. It installs PyTorch which is needed for mypy to pass. Always run tests, mypy, and ruff at the end of your task and fix any issues.

## Architecture

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,8 @@ Options:

### Setup

The `--all-extras` flag is required for development as it installs PyTorch, which is needed for mypy type checking to pass.

```bash
git clone https://github.com/fmueller/scribae.git
cd scribae
Expand Down
5 changes: 2 additions & 3 deletions src/scribae/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"save_prompt_artifacts",
]


class BriefingError(Exception):
"""Raised when a brief cannot be generated."""

Expand Down Expand Up @@ -409,9 +410,7 @@ def _select_idea(
if len(ideas.ideas) == 1:
return ideas.ideas[0]

raise BriefValidationError(
"Select an idea with --idea (id or 1-based index), or set idea_id in note frontmatter."
)
raise BriefValidationError("Select an idea with --idea (id or 1-based index), or set idea_id in note frontmatter.")


def _metadata_idea_id(metadata: dict[str, Any]) -> str | None:
Expand Down
100 changes: 70 additions & 30 deletions src/scribae/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,26 @@
from .llm import LLM_OUTPUT_RETRIES, LLM_TIMEOUT_SECONDS, OpenAISettings, apply_optional_settings, make_model
from .project import ProjectConfig
from .prompts.feedback import FEEDBACK_SYSTEM_PROMPT, FeedbackPromptBundle, build_feedback_prompt_bundle
from .prompts.feedback_categories import CATEGORY_DEFINITIONS

# Pattern to match emoji characters across common Unicode ranges
_EMOJI_PATTERN = re.compile(
"["
"\U0001F600-\U0001F64F" # emoticons
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F1E0-\U0001F1FF" # flags
"\U00002700-\U000027BF" # dingbats
"\U0001F900-\U0001F9FF" # supplemental symbols & pictographs
"\U0001FA00-\U0001FA6F" # chess symbols, extended-A
"\U0001FA70-\U0001FAFF" # symbols & pictographs extended-A
"\U00002600-\U000026FF" # misc symbols
"\U0001F700-\U0001F77F" # alchemical symbols
"\U0001F780-\U0001F7FF" # geometric shapes extended
"\U0001F800-\U0001F8FF" # supplemental arrows-C
"\U0001F3FB-\U0001F3FF" # skin tone modifiers
"\uFE0F" # variation selector-16 (emoji presentation)
"\u200D" # zero-width joiner (used in combined emojis)
"\U0001f600-\U0001f64f" # emoticons
"\U0001f300-\U0001f5ff" # symbols & pictographs
"\U0001f680-\U0001f6ff" # transport & map symbols
"\U0001f1e0-\U0001f1ff" # flags
"\U00002700-\U000027bf" # dingbats
"\U0001f900-\U0001f9ff" # supplemental symbols & pictographs
"\U0001fa00-\U0001fa6f" # chess symbols, extended-A
"\U0001fa70-\U0001faff" # symbols & pictographs extended-A
"\U00002600-\U000026ff" # misc symbols
"\U0001f700-\U0001f77f" # alchemical symbols
"\U0001f780-\U0001f7ff" # geometric shapes extended
"\U0001f800-\U0001f8ff" # supplemental arrows-C
"\U0001f3fb-\U0001f3ff" # skin tone modifiers
"\ufe0f" # variation selector-16 (emoji presentation)
"\u200d" # zero-width joiner (used in combined emojis)
"]+",
flags=re.UNICODE,
)
Expand Down Expand Up @@ -232,13 +233,22 @@ class FeedbackFocus(str):
STYLE = "style"
EVIDENCE = "evidence"

ALLOWED: frozenset[str] = frozenset(CATEGORY_DEFINITIONS.keys())

@classmethod
def from_raw(cls, value: str) -> FeedbackFocus:
lowered = value.lower().strip()
allowed = {cls.SEO, cls.STRUCTURE, cls.CLARITY, cls.STYLE, cls.EVIDENCE}
if lowered not in allowed:
raise FeedbackValidationError("--focus must be seo, structure, clarity, style, or evidence.")
return cls(lowered)
def parse_list(cls, value: str) -> list[str]:
parts = [item.strip() for item in value.split(",") if item.strip()]
if not parts:
raise FeedbackValidationError("--focus must include at least one category.")
normalized: list[str] = []
for part in parts:
lowered = part.lower()
if lowered not in cls.ALLOWED:
allowed_list = ", ".join(sorted(cls.ALLOWED))
raise FeedbackValidationError(f"--focus must be one of: {allowed_list}.")
if lowered not in normalized:
normalized.append(lowered)
return normalized


@dataclass(frozen=True)
Expand All @@ -263,7 +273,7 @@ class FeedbackContext:
brief: SeoBrief
project: ProjectConfig
note: NoteDetails | None
focus: str | None
focus: list[str] | None
language: str
selected_outline: list[str]
selected_sections: list[BodySection]
Expand All @@ -281,7 +291,7 @@ def prepare_context(
project: ProjectConfig,
note_path: Path | None = None,
language: str | None = None,
focus: str | None = None,
focus: list[str] | None = None,
section_range: tuple[int, int] | None = None,
max_body_chars: int = 12000,
max_note_chars: int = 6000,
Expand Down Expand Up @@ -360,9 +370,7 @@ def generate_feedback_report(

resolved_settings = OpenAISettings.from_env()
llm_agent: Agent[None, FeedbackReport] = (
agent
if agent is not None
else _create_agent(model_name, temperature=temperature, top_p=top_p, seed=seed)
agent if agent is not None else _create_agent(model_name, temperature=temperature, top_p=top_p, seed=seed)
)

_report(reporter, f"Calling model '{model_name}' via {resolved_settings.base_url}")
Expand Down Expand Up @@ -392,6 +400,8 @@ def generate_feedback_report(
except Exception as exc: # pragma: no cover - surfaced to CLI
raise FeedbackLLMError(f"LLM request failed: {exc}") from exc

# Remap any out-of-scope categories to "other"
report = _normalize_finding_categories(report, context.focus)
return report


Expand Down Expand Up @@ -437,9 +447,7 @@ def render_markdown(report: FeedbackReport) -> str:
if report.findings:
for finding in report.findings:
location = _format_location(finding.location)
sections.append(
f"- **{finding.severity.upper()}** [{finding.category}] {finding.message}{location}"
)
sections.append(f"- **{finding.severity.upper()}** [{finding.category}] {finding.message}{location}")
else:
sections.extend(_render_list([]))
sections.append("")
Expand Down Expand Up @@ -509,7 +517,7 @@ class _FeedbackPromptContext:
note_excerpt: str | None
project: ProjectConfig
language: str
focus: str | None
focus: list[str] | None
selected_outline: list[str]
selected_sections: list[dict[str, str]]

Expand Down Expand Up @@ -556,6 +564,38 @@ def _feedback_language_text(report: FeedbackReport) -> str:
return "\n".join([issue_text, strength_text, findings, checklist, section_notes]).strip()


def _normalize_finding_categories(report: FeedbackReport, focus: list[str] | None) -> FeedbackReport:
"""Remap any finding categories outside the focus scope to 'other'.

If focus is None (all categories), no remapping is performed.
"""
if focus is None:
return report

allowed = set(focus) | {"other"}
needs_remap = any(f.category not in allowed for f in report.findings)
if not needs_remap:
return report

remapped_findings = [
FeedbackFinding(
severity=f.severity,
category=f.category if f.category in allowed else "other",
message=f.message,
location=f.location,
)
for f in report.findings
]
return FeedbackReport(
summary=report.summary,
brief_alignment=report.brief_alignment,
section_notes=report.section_notes,
evidence_gaps=report.evidence_gaps,
findings=remapped_findings,
checklist=report.checklist,
)


def _load_body(body_path: Path, *, max_chars: int) -> BodyDocument:
try:
post = frontmatter.load(body_path)
Expand Down
4 changes: 2 additions & 2 deletions src/scribae/feedback_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def feedback_command(
focus: str | None = typer.Option( # noqa: B008
None,
"--focus",
help="Narrow the review scope: seo|structure|clarity|style|evidence.",
help="Narrow the review scope (comma-separated): seo|structure|clarity|style|evidence.",
),
output_format: str = typer.Option( # noqa: B008
FeedbackFormat.MARKDOWN,
Expand Down Expand Up @@ -187,7 +187,7 @@ def feedback_command(
focus_value = None
if focus:
try:
focus_value = str(FeedbackFocus.from_raw(focus))
focus_value = FeedbackFocus.parse_list(focus)
except FeedbackValidationError as exc:
typer.secho(str(exc), err=True, fg=typer.colors.RED)
raise typer.Exit(exc.exit_code) from exc
Expand Down
12 changes: 3 additions & 9 deletions src/scribae/idea.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,7 @@ def prepare_context(
)
_report(reporter, "Prepared idea-generation prompt.")

return IdeaContext(
note=note, project=project, prompts=prompts, language=language_resolution.language
)
return IdeaContext(note=note, project=project, prompts=prompts, language=language_resolution.language)


def generate_ideas(
Expand Down Expand Up @@ -216,9 +214,7 @@ def save_prompt_artifacts(
prompt_path = destination / f"{stamp}-{slug}-ideas.prompt.txt"
note_path = destination / f"{stamp}-note.txt"

prompt_payload = (
f"SYSTEM PROMPT:\n{context.prompts.system_prompt}\n\nUSER PROMPT:\n{context.prompts.user_prompt}\n"
)
prompt_payload = f"SYSTEM PROMPT:\n{context.prompts.system_prompt}\n\nUSER PROMPT:\n{context.prompts.user_prompt}\n"
prompt_path.write_text(prompt_payload, encoding="utf-8")
note_path.write_text(context.note.body, encoding="utf-8")

Expand Down Expand Up @@ -248,9 +244,7 @@ def _create_agent(


def _idea_language_text(ideas: IdeaList) -> str:
return "\n".join(
f"{item.title} {item.description} {item.why}" for item in ideas.ideas
)
return "\n".join(f"{item.title} {item.description} {item.why}" for item in ideas.ideas)


def _invoke_agent(agent: Agent[None, IdeaList], prompt: str, *, timeout_seconds: float) -> IdeaList:
Expand Down
3 changes: 1 addition & 2 deletions src/scribae/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,7 @@ def ensure_language_output(

def _append_language_correction(prompt: str, expected_language: str) -> str:
correction = (
"\n\n[LANGUAGE CORRECTION]\n"
f"Regenerate the full response strictly in language code '{expected_language}'."
f"\n\n[LANGUAGE CORRECTION]\nRegenerate the full response strictly in language code '{expected_language}'."
)
return f"{prompt}{correction}"

Expand Down
5 changes: 3 additions & 2 deletions src/scribae/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ def configure_environment(self) -> None:
os.environ["OPENAI_API_KEY"] = self.api_key


def make_model(model_name: str, *, model_settings: ModelSettings,
settings: OpenAISettings | None = None) -> OpenAIChatModel:
def make_model(
model_name: str, *, model_settings: ModelSettings, settings: OpenAISettings | None = None
) -> OpenAIChatModel:
"""Return an OpenAI-compatible model configured for local/remote endpoints."""
resolved_settings = settings or OpenAISettings.from_env()
provider = OpenAIProvider(base_url=resolved_settings.base_url, api_key=resolved_settings.api_key)
Expand Down
1 change: 1 addition & 0 deletions src/scribae/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
def app_callback() -> None:
"""Root Scribae CLI callback."""


app.command("idea", help="Brainstorm article ideas from a note with project-aware guidance.")(idea_command)
app.command(
"brief",
Expand Down
3 changes: 1 addition & 2 deletions src/scribae/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,7 @@ def generate_metadata(

_report(
reporter,
f"Calling model '{model_name}' via {resolved_settings.base_url}"
+ (f" (reason: {reason})" if reason else ""),
f"Calling model '{model_name}' via {resolved_settings.base_url}" + (f" (reason: {reason})" if reason else ""),
)

try:
Expand Down
4 changes: 1 addition & 3 deletions src/scribae/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,7 @@ def _resolve_project_path(name: str, *, base_dir: Path | None = None) -> Path:
if resolved.exists():
return resolved

raise FileNotFoundError(
f"Project config {search_dir / f'{name}.yaml'} or {search_dir / f'{name}.yml'} not found"
)
raise FileNotFoundError(f"Project config {search_dir / f'{name}.yaml'} or {search_dir / f'{name}.yml'} not found")


def _merge_with_defaults(data: Mapping[str, Any]) -> ProjectConfig:
Expand Down
Loading