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
14 changes: 9 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ FIRENZE_ENVIRONMENT=dev
FIRENZE_DATABASE_URL=postgresql+psycopg://firenze:firenze@localhost:5433/firenze
FIRENZE_REDIS_URL=redis://localhost:6379/0

# Provedor de LLM. Nunca commitar valor real.
# O SDK lê ANTHROPIC_API_KEY sozinho — sem prefixo FIRENZE_.
# ANTHROPIC_API_KEY=
# Modelo: prosa, fake ou none (ADR-0007, ADR-0008).
# fake -> sem rede, sem chave, texto sintético marcado com [fake]
# prosa -> Magalu Prosa; base_url e chave saem do console da Magalu
# Padrão none porque o Prosa ainda está em piloto.
FIRENZE_MODEL_PROVIDER=none
FIRENZE_MODEL_NAME=
FIRENZE_MODEL_BASE_URL=

# Qual modelo escreve o verniz. É o botão que move o custo por caso.
FIRENZE_VENEER_MODEL=claude-haiku-4-5
# Nunca commitar valor real. O .env não é versionado.
FIRENZE_MODEL_API_KEY=
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Next.js (front) + FastAPI/LangGraph (core de IA) + Postgres com pgvector.
- Toda saída de LLM é validada por schema Pydantic versionado.
Mudou o schema, roda os evals antes de commitar.
- Prompts vivem em `prompts/`, versionados, nunca em string literal.
- Modelo só se chama pela porta em `firenze.model`. Nenhum outro módulo
importa SDK de fornecedor. (ADR-0007)
- Código em inglês, docs em português, conteúdo do jogo em português.
Domínio guarda estrutura; frase pronta só no catálogo. (ADR-0005/0006)
- Conventional Commits. Nada direto na main.
Expand Down
2 changes: 1 addition & 1 deletion apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.34",
"pydantic-settings>=2.7",
"anthropic>=1.2.0",
"openai>=3.6.0",
]

[project.scripts]
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/firenze/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from firenze.domain import CaseWithSolution, FactKind, Role
from firenze.generation import UnsolvableCase, generate, solve
from firenze.i18n import DEFAULT_LOCALE, Catalog, UnknownLocale, available_locales, load
from firenze.model import ModelUnavailable, resolve
from firenze.veneer import CaseVeneer, VeneerRejected, VeneerUnavailable, write


Expand Down Expand Up @@ -111,8 +112,14 @@ def main(argv: Sequence[str] | None = None) -> int:
veneer = None
if args.veneer:
try:
veneer = write(full.case, catalog, model=settings.veneer_model)
except (VeneerUnavailable, VeneerRejected) as failure:
model = resolve(
settings.model_provider,
model=settings.model_name,
base_url=settings.model_base_url,
api_key=settings.model_api_key.get_secret_value(),
)
veneer = write(full.case, catalog, model=model)
except (VeneerUnavailable, VeneerRejected, ModelUnavailable) as failure:
# The case is playable without prose. Degrading beats failing.
print(f"veneer skipped: {failure}", file=sys.stderr)

Expand Down
19 changes: 16 additions & 3 deletions apps/api/src/firenze/config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Literal

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict

Environment = Literal["dev", "staging", "prod"]
Expand All @@ -13,9 +14,21 @@ class Settings(BaseSettings):
environment: Environment = "dev"
database_url: str = "postgresql+psycopg://firenze:firenze@localhost:5433/firenze"
redis_url: str = "redis://localhost:6379/0"
veneer_model: str = "claude-haiku-4-5"
"""Which model writes the veneer. Env-switchable because it is the one
knob that moves cost per case."""
model_provider: str = "none"
"""Which provider backs the model port: prosa, fake, or none.

`none` by default even though Prosa is the decision (ADR-0008): the product
is still in pilot, and a default that tried to reach an endpoint nobody has
credentials for would turn a missing key into a confusing failure."""

model_name: str = ""
"""Which model at that provider, from its catalog."""

model_base_url: str = ""
"""Endpoint of the OpenAI-compatible API. Prosa shows it beside the API key."""

model_api_key: SecretStr = SecretStr("")
"""Secret so it does not land in a log by accident."""


settings = Settings()
46 changes: 46 additions & 0 deletions apps/api/src/firenze/model/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Language models, behind one interface.

`resolve()` is the only place that knows which providers exist. Everything else
depends on the port.
"""

from firenze.model.fake import FakeModel
from firenze.model.openai_compatible import OpenAICompatibleModel
from firenze.model.port import ModelRefused, ModelUnavailable, StructuredModel


def resolve(
provider: str,
*,
model: str = "",
base_url: str = "",
api_key: str = "",
) -> StructuredModel:
"""Build the configured model, or raise `ModelUnavailable`.

`none` is not an error state to be worked around: a build that quietly
picked a provider would be making the decision on the reader's behalf.
"""
if provider == "none":
raise ModelUnavailable(
"no model provider configured; set FIRENZE_MODEL_PROVIDER to one of: prosa, fake"
)
if provider == "fake":
return FakeModel()
if provider == "prosa":
# Prosa speaks the OpenAI dialect (ADR-0008), so the adapter is generic
# and the provider name is only a label over a base URL.
if not model:
raise ModelUnavailable("provider 'prosa' needs FIRENZE_MODEL_NAME set")
return OpenAICompatibleModel(model=model, base_url=base_url, api_key=api_key)
raise ModelUnavailable(f"unknown model provider {provider!r}")


__all__ = [
"FakeModel",
"ModelRefused",
"ModelUnavailable",
"OpenAICompatibleModel",
"StructuredModel",
"resolve",
]
100 changes: 100 additions & 0 deletions apps/api/src/firenze/model/fake.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""A model that needs no network, no key and no money.

It exists so the game can be built and played before a provider is chosen, and
so a front end can be developed against a running backend without anyone paying
per keystroke. It fills a schema with deterministic, obviously-synthetic text.

Two rules keep it honest:

- **It never pretends to be a real model.** `name` says `fake`, and that string
is recorded on everything it writes. A veneer produced here is traceable as
produced here.
- **It is deterministic.** Same prompt, same output. A fake that varied would
make a failing test look flaky, which is worse than no fake at all.

It is not a mock of any provider and does not imitate one. It satisfies the
port, nothing more — and note what that means: it produces output that fits a
**schema**, never output that fits a **case**. Domain validation will reject a
fake veneer, because the cast it invents is not the cast of any real mystery,
and that rejection is correct rather than a shortcoming. The fake earns its keep
on pipelines whose correctness does not depend on the content: schema
validation, stance transitions, output filters, turn accounting.
"""

import hashlib
from enum import Enum
from typing import Any, get_args, get_origin

from pydantic import BaseModel

from firenze.model.port import ModelRefused, Schema

MARKER = "[fake]"


class FakeModel:
"""Fills any schema with placeholder text derived from the prompt."""

def __init__(self, *, refuse: bool = False) -> None:
self._refuse = refuse

@property
def name(self) -> str:
return "fake"

def complete(
self,
*,
system: str,
user: str,
schema: type[Schema],
max_tokens: int,
) -> Schema:
if self._refuse:
raise ModelRefused("the fake model was asked to refuse")

seed = hashlib.blake2s(f"{system}{user}".encode(), digest_size=4).hexdigest()
return _fill(schema, seed=seed, path="")


def _fill(schema: type[Schema], *, seed: str, path: str) -> Schema:
values: dict[str, Any] = {}
for name, field in schema.model_fields.items():
values[name] = _value_for(field.annotation, seed=seed, path=f"{path}.{name}")
return schema(**values)


def _value_for(annotation: Any, *, seed: str, path: str) -> Any:
if annotation is str:
return f"{MARKER} {path.lstrip('.')} {seed}"
if annotation is int:
return 0
if annotation is float:
return 0.0
if annotation is bool:
return False

origin = get_origin(annotation)
args = get_args(annotation)

if origin in (tuple, list, set, frozenset):
# One element, not zero: an empty collection would pass a schema and
# then fail a validation rule for a reason unrelated to the fake.
inner = args[0] if args else str
item = _value_for(inner, seed=seed, path=f"{path}[0]")
return origin([item]) if origin is not tuple else (item,)

if isinstance(annotation, type) and issubclass(annotation, Enum):
# The first member, deterministically. A stance machine fed a random
# enum would fail for a reason that has nothing to do with the code.
return next(iter(annotation))

if isinstance(annotation, type) and issubclass(annotation, BaseModel):
return _fill(annotation, seed=seed, path=path)

if args: # Optional[X], X | None, Literal[...]
for candidate in args:
if candidate is not type(None):
return _value_for(candidate, seed=seed, path=path)

return f"{MARKER} {path.lstrip('.')}"
Loading
Loading