From 289bd7c875342167ae714c4bab8a1735e24b5b0a Mon Sep 17 00:00:00 2001 From: Madeuss Date: Sun, 30 Aug 2026 18:24:48 -0300 Subject: [PATCH 1/3] feat(api): let a model write the case, without letting it decide anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes E1. The generator produces a structure the solver has approved; this turns it into prose a player would want to read. Two boundaries define the module, and they are the whole design. It takes `Case`, never `CaseWithSolution` — the same signature discipline as the solver, for a different reason. A writer who knew the culprit would telegraph it. Nobody would mean to; the guilty character would simply end up with the sharper description, and the mystery would be over before the first question. A test asserts the stronger property: every suspect appears exactly once in the prompt, so nothing in the text distinguishes them. It is only ever shown public facts. The veneer has no use for a restricted one, so it never receives one and cannot leak one. The canary check on the output is therefore not aimed at the model — it is aimed at us, because a canary in this output means context assembly upstream is broken (RN-010, RN-012). The output schema is narrow on purpose: role, appearance, manner, and a scene. There is no field in which to invent evidence, move somebody through the house, or name a killer. What the schema cannot prevent, validation rejects: an invented or dropped character, a duplicated id, one suspect described with another's name, a blank or runaway field. A rejected draft is discarded rather than repaired — a model that broke the cast once will break it differently on a patch. Failure is not fatal. No key, a refusal, a network error, a rejected draft: the case still prints, with a note on stderr. Prose is a luxury; the mystery is not. The prompt is a versioned file in prompts/veneer/, never a string literal, and the tests read it rather than restating it. Also finishes the ADR-0006 rename that phase 0 left behind: Configuracao became Settings, and the health endpoint now answers with version and environment. 63 tests, all of them keyless — the model is a stub returning exactly the drafts that must not reach a player. Waiting for a real model to produce one of those by chance is not a test strategy. --- .env.example | 10 +- Makefile | 4 +- apps/api/openapi.json | 26 +-- apps/api/pyproject.toml | 1 + apps/api/src/mansao/cli.py | 30 ++- apps/api/src/mansao/config.py | 13 +- apps/api/src/mansao/i18n/messages/en.json | 56 +++-- apps/api/src/mansao/i18n/messages/pt-BR.json | 56 +++-- apps/api/src/mansao/main.py | 28 +-- apps/api/src/mansao/veneer/__init__.py | 17 ++ apps/api/src/mansao/veneer/models.py | 54 +++++ apps/api/src/mansao/veneer/validation.py | 103 +++++++++ apps/api/src/mansao/veneer/writer.py | 146 +++++++++++++ apps/api/tests/test_health.py | 2 +- apps/api/tests/test_veneer.py | 218 +++++++++++++++++++ apps/api/uv.lock | 135 ++++++++++++ infra/compose/docker-compose.yml | 2 +- prompts/veneer/v1.md | 51 +++++ 18 files changed, 879 insertions(+), 73 deletions(-) create mode 100644 apps/api/src/mansao/veneer/__init__.py create mode 100644 apps/api/src/mansao/veneer/models.py create mode 100644 apps/api/src/mansao/veneer/validation.py create mode 100644 apps/api/src/mansao/veneer/writer.py create mode 100644 apps/api/tests/test_veneer.py create mode 100644 prompts/veneer/v1.md diff --git a/.env.example b/.env.example index eed4dde..ba5a9a1 100644 --- a/.env.example +++ b/.env.example @@ -6,9 +6,13 @@ POSTGRES_PASSWORD=mansao POSTGRES_DB=mansao # API — prefixo MANSAO_ (ver apps/api/src/mansao/config.py) -MANSAO_AMBIENTE=dev +MANSAO_ENVIRONMENT=dev MANSAO_DATABASE_URL=postgresql+psycopg://mansao:mansao@localhost:5433/mansao MANSAO_REDIS_URL=redis://localhost:6379/0 -# Provedor de LLM — chega na fase 2. Nunca commitar valor real. -# MANSAO_ANTHROPIC_API_KEY= +# Provedor de LLM. Nunca commitar valor real. +# O SDK lê ANTHROPIC_API_KEY sozinho — sem prefixo MANSAO_. +# ANTHROPIC_API_KEY= + +# Qual modelo escreve o verniz. É o botão que move o custo por caso. +MANSAO_VENEER_MODEL=claude-opus-5 diff --git a/Makefile b/Makefile index e2506b2..8e3d755 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,8 @@ install: ## sincroniza o venv de apps/api a partir do uv.lock api: ## roda a API local com reload (sem container) cd $(API) && uv run uvicorn mansao.main:app --reload --port 8000 -case: ## gera um caso (make case SEED=42 [LOCALE=en] [REVEAL=1]) - cd $(API) && uv run mansao generate --seed $(or $(SEED),1) --locale $(or $(LOCALE),pt-BR) $(if $(REVEAL),--reveal,) +case: ## gera um caso (make case SEED=42 [LOCALE=en] [REVEAL=1] [VENEER=1]) + cd $(API) && uv run mansao generate --seed $(or $(SEED),1) --locale $(or $(LOCALE),pt-BR) $(if $(REVEAL),--reveal,) $(if $(VENEER),--veneer,) openapi: ## regenera apps/api/openapi.json cd $(API) && uv run python scripts/dump_openapi.py diff --git a/apps/api/openapi.json b/apps/api/openapi.json index bb7ecca..5138311 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1,16 +1,16 @@ { "components": { "schemas": { - "Saude": { - "description": "Resposta do liveness probe.", + "Health": { + "description": "Liveness probe response.", "properties": { - "ambiente": { + "environment": { "enum": [ "dev", "staging", "prod" ], - "title": "Ambiente", + "title": "Environment", "type": "string" }, "status": { @@ -19,23 +19,23 @@ "title": "Status", "type": "string" }, - "versao": { - "title": "Versao", + "version": { + "title": "Version", "type": "string" } }, "required": [ - "versao", - "ambiente" + "version", + "environment" ], - "title": "Saude", + "title": "Health", "type": "object" } } }, "info": { - "description": "Dono de todo o domínio determinístico e de toda chamada a LLM (ADR-0003). O front nunca fala com o provedor de modelo nem com o banco.", - "summary": "Core de IA e domínio do jogo de mistério.", + "description": "Owns the whole deterministic domain and every call to a model (ADR-0003). The front end never talks to the model provider or to the database.", + "summary": "AI core and domain of the mystery game.", "title": "Mansão API", "version": "0.1.0" }, @@ -43,14 +43,14 @@ "paths": { "/health": { "get": { - "description": "Diz se o processo está de pé. Não toca no banco de propósito:\nreadiness com dependências chega junto com o schema (fase 1).", + "description": "Whether the process is up. Deliberately does not touch the database:\nreadiness with dependencies arrives with the schema (phase 1).", "operationId": "health_health_get", "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Saude" + "$ref": "#/components/schemas/Health" } } }, diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 48aa53e..b511b12 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -7,6 +7,7 @@ dependencies = [ "fastapi>=0.115", "uvicorn[standard]>=0.34", "pydantic-settings>=2.7", + "anthropic>=1.2.0", ] [project.scripts] diff --git a/apps/api/src/mansao/cli.py b/apps/api/src/mansao/cli.py index fdc99f6..33a89e0 100644 --- a/apps/api/src/mansao/cli.py +++ b/apps/api/src/mansao/cli.py @@ -14,12 +14,19 @@ import sys from collections.abc import Sequence +from mansao.config import settings from mansao.domain import CaseWithSolution, FactKind, Role from mansao.generation import UnsolvableCase, generate, solve from mansao.i18n import DEFAULT_LOCALE, Catalog, UnknownLocale, available_locales, load +from mansao.veneer import CaseVeneer, VeneerRejected, VeneerUnavailable, write -def _briefing(full: CaseWithSolution, catalog: Catalog, reveal: bool) -> str: +def _briefing( + full: CaseWithSolution, + catalog: Catalog, + reveal: bool, + veneer: CaseVeneer | None = None, +) -> str: case = full.case label = catalog.label lines = [ @@ -28,9 +35,15 @@ def _briefing(full: CaseWithSolution, catalog: Catalog, reveal: bool) -> str: "", label("cast"), ] + if veneer is not None: + lines = [lines[0], "", veneer.scene, "", label("cast")] for character in case.cast: mark = f" ({label('victim')})" if character.role is Role.victim else "" lines.append(f" {character.id:8} {character.name}{mark}") + if veneer is not None and character.role is not Role.victim: + surface = veneer.for_character(character.id) + lines.append(f" {surface.role_title}. {surface.appearance}") + lines.append(f" {surface.manner}") lines += ["", label("known")] lines += [f" {f.id} {catalog.fact(case, f)}" for f in case.facts if f.scope.public] @@ -75,6 +88,11 @@ def main(argv: Sequence[str] | None = None) -> int: gen.add_argument("--suspects", type=int, default=6) gen.add_argument("--locale", default=DEFAULT_LOCALE, help=f"one of {available_locales()}") gen.add_argument("--reveal", action="store_true", help="show solution and solver chain") + gen.add_argument( + "--veneer", + action="store_true", + help="have a model write the cast and the scene; needs ANTHROPIC_API_KEY", + ) gen.add_argument("--json", action="store_true", help="print the case as JSON, no solution") args = parser.parse_args(argv) @@ -90,10 +108,18 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"error: {failure}", file=sys.stderr) return 1 + veneer = None + if args.veneer: + try: + veneer = write(full.case, catalog, model=settings.veneer_model) + except (VeneerUnavailable, VeneerRejected) as failure: + # The case is playable without prose. Degrading beats failing. + print(f"veneer skipped: {failure}", file=sys.stderr) + if args.json: print(full.case.model_dump_json(indent=2)) else: - print(_briefing(full, catalog, reveal=args.reveal)) + print(_briefing(full, catalog, reveal=args.reveal, veneer=veneer)) return 0 diff --git a/apps/api/src/mansao/config.py b/apps/api/src/mansao/config.py index 27cb9d4..5d1eaf8 100644 --- a/apps/api/src/mansao/config.py +++ b/apps/api/src/mansao/config.py @@ -2,17 +2,20 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -Ambiente = Literal["dev", "staging", "prod"] +Environment = Literal["dev", "staging", "prod"] -class Configuracao(BaseSettings): - """Configuração do processo. Prefixo MANSAO_ para não colidir com nada.""" +class Settings(BaseSettings): + """Process configuration. MANSAO_ prefix so nothing collides.""" model_config = SettingsConfigDict(env_prefix="MANSAO_", env_file=".env", extra="ignore") - ambiente: Ambiente = "dev" + environment: Environment = "dev" database_url: str = "postgresql+psycopg://mansao:mansao@localhost:5433/mansao" redis_url: str = "redis://localhost:6379/0" + veneer_model: str = "claude-opus-5" + """Which model writes the veneer. Env-switchable because it is the one + knob that moves cost per case.""" -config = Configuracao() +settings = Settings() diff --git a/apps/api/src/mansao/i18n/messages/en.json b/apps/api/src/mansao/i18n/messages/en.json index 36b8bf6..b7f8f78 100644 --- a/apps/api/src/mansao/i18n/messages/en.json +++ b/apps/api/src/mansao/i18n/messages/en.json @@ -1,24 +1,50 @@ { - "clock": { "cycle": 12, "pattern": "{hour}:{minute} {suffix}", "am": "am", "pm": "pm" }, - + "clock": { + "cycle": 12, + "pattern": "{hour}:{minute} {suffix}", + "am": "am", + "pm": "pm" + }, "rooms": { - "library": { "name": "the library", "preposition": "in" }, - "parlour": { "name": "the parlour", "preposition": "in" }, - "dining_room": { "name": "the dining room", "preposition": "in" }, - "kitchen": { "name": "the kitchen", "preposition": "in" }, - "cellar": { "name": "the wine cellar", "preposition": "in" }, - "study": { "name": "the study", "preposition": "in" }, - "conservatory": { "name": "the conservatory", "preposition": "in" }, - "basement": { "name": "the basement", "preposition": "in" } + "library": { + "name": "the library", + "preposition": "in" + }, + "parlour": { + "name": "the parlour", + "preposition": "in" + }, + "dining_room": { + "name": "the dining room", + "preposition": "in" + }, + "kitchen": { + "name": "the kitchen", + "preposition": "in" + }, + "cellar": { + "name": "the wine cellar", + "preposition": "in" + }, + "study": { + "name": "the study", + "preposition": "in" + }, + "conservatory": { + "name": "the conservatory", + "preposition": "in" + }, + "basement": { + "name": "the basement", + "preposition": "in" + } }, - "facts": { "body": "{victim}'s body was found {room}, around {time}.", "presence": "{character} was {room} at {time}, with {witness} present.", "secret": "{character} {secret} — and was {room} at {time}.", "clue": "{witness} saw, {room}, a personal item of {character}'s that had no business being there at {time}." }, - "secrets": { "cellar_wine": "had been siphoning wine from the cellar to resell", "reads_the_mail": "had been reading the host's correspondence", @@ -27,21 +53,18 @@ "forged_signature": "forged a signature in the ledger", "hoarded_objects": "was keeping objects that did not belong to them" }, - "means": { "bronze_candlestick": "a bronze candlestick", "poisoned_decanter": "poison in the decanter", "letter_opener": "a letter opener", "curtain_cord": "a curtain cord" }, - "motives": { "inheritance": "an inheritance about to be redirected the next morning", "blackmail": "blackmail about to become a scandal", "dissolved_partnership": "a partnership dissolved without settling accounts", "old_forgery": "a letter proving an old forgery" }, - "labels": { "case": "Case", "generator": "generator", @@ -59,6 +82,7 @@ "deduced": "deduced", "chain": "chain", "secrets": "SECRETS (why the innocent lie)", - "hidden": "Solution withheld. Use --reveal to see it." + "hidden": "Solution withheld. Use --reveal to see it.", + "language_name": "English" } } diff --git a/apps/api/src/mansao/i18n/messages/pt-BR.json b/apps/api/src/mansao/i18n/messages/pt-BR.json index d65adce..5a65828 100644 --- a/apps/api/src/mansao/i18n/messages/pt-BR.json +++ b/apps/api/src/mansao/i18n/messages/pt-BR.json @@ -1,24 +1,50 @@ { - "clock": { "cycle": 24, "pattern": "{hour}h{minute}", "am": "", "pm": "" }, - + "clock": { + "cycle": 24, + "pattern": "{hour}h{minute}", + "am": "", + "pm": "" + }, "rooms": { - "library": { "name": "biblioteca", "preposition": "na" }, - "parlour": { "name": "salão", "preposition": "no" }, - "dining_room": { "name": "sala de jantar", "preposition": "na" }, - "kitchen": { "name": "cozinha", "preposition": "na" }, - "cellar": { "name": "adega", "preposition": "na" }, - "study": { "name": "escritório", "preposition": "no" }, - "conservatory": { "name": "jardim de inverno", "preposition": "no" }, - "basement": { "name": "porão", "preposition": "no" } + "library": { + "name": "biblioteca", + "preposition": "na" + }, + "parlour": { + "name": "salão", + "preposition": "no" + }, + "dining_room": { + "name": "sala de jantar", + "preposition": "na" + }, + "kitchen": { + "name": "cozinha", + "preposition": "na" + }, + "cellar": { + "name": "adega", + "preposition": "na" + }, + "study": { + "name": "escritório", + "preposition": "no" + }, + "conservatory": { + "name": "jardim de inverno", + "preposition": "no" + }, + "basement": { + "name": "porão", + "preposition": "no" + } }, - "facts": { "body": "O corpo de {victim} foi encontrado {room}, por volta das {time}.", "presence": "{character} estava {room} às {time}, na presença de {witness}.", "secret": "{character} {secret} — estava {room} às {time}.", "clue": "{witness} viu, {room}, um objeto pessoal de {character} que não deveria estar ali às {time}." }, - "secrets": { "cellar_wine": "desviava vinho da adega para revender", "reads_the_mail": "lia a correspondência do anfitrião", @@ -27,21 +53,18 @@ "forged_signature": "falsificou uma assinatura no livro-caixa", "hoarded_objects": "guardava objetos que não lhe pertenciam" }, - "means": { "bronze_candlestick": "castiçal de bronze", "poisoned_decanter": "veneno no decanter", "letter_opener": "abridor de cartas", "curtain_cord": "corda de cortina" }, - "motives": { "inheritance": "herança que seria redirecionada na manhã seguinte", "blackmail": "chantagem prestes a virar escândalo", "dissolved_partnership": "sociedade desfeita sem acerto de contas", "old_forgery": "carta que provava uma falsificação antiga" }, - "labels": { "case": "Caso", "generator": "gerador", @@ -59,6 +82,7 @@ "deduced": "deduziu", "chain": "cadeia", "secrets": "SEGREDOS (por que os inocentes mentem)", - "hidden": "Solução omitida. Use --reveal para vê-la." + "hidden": "Solução omitida. Use --reveal para vê-la.", + "language_name": "português do Brasil" } } diff --git a/apps/api/src/mansao/main.py b/apps/api/src/mansao/main.py index 11fdc45..9586b10 100644 --- a/apps/api/src/mansao/main.py +++ b/apps/api/src/mansao/main.py @@ -4,35 +4,35 @@ from pydantic import BaseModel from mansao import __version__ -from mansao.config import Ambiente, config +from mansao.config import Environment, settings -class Saude(BaseModel): - """Resposta do liveness probe.""" +class Health(BaseModel): + """Liveness probe response.""" status: Literal["ok"] = "ok" - versao: str - ambiente: Ambiente + version: str + environment: Environment -def criar_app() -> FastAPI: +def create_app() -> FastAPI: app = FastAPI( title="Mansão API", version=__version__, - summary="Core de IA e domínio do jogo de mistério.", + summary="AI core and domain of the mystery game.", description=( - "Dono de todo o domínio determinístico e de toda chamada a LLM (ADR-0003). " - "O front nunca fala com o provedor de modelo nem com o banco." + "Owns the whole deterministic domain and every call to a model (ADR-0003). " + "The front end never talks to the model provider or to the database." ), ) @app.get("/health", tags=["infra"], summary="Liveness") - def health() -> Saude: - """Diz se o processo está de pé. Não toca no banco de propósito: - readiness com dependências chega junto com o schema (fase 1).""" - return Saude(versao=__version__, ambiente=config.ambiente) + def health() -> Health: + """Whether the process is up. Deliberately does not touch the database: + readiness with dependencies arrives with the schema (phase 1).""" + return Health(version=__version__, environment=settings.environment) return app -app = criar_app() +app = create_app() diff --git a/apps/api/src/mansao/veneer/__init__.py b/apps/api/src/mansao/veneer/__init__.py new file mode 100644 index 0000000..125d6da --- /dev/null +++ b/apps/api/src/mansao/veneer/__init__.py @@ -0,0 +1,17 @@ +"""Prose over an approved structure. The model writes; it decides nothing.""" + +from mansao.veneer.models import CaseVeneer, CharacterVeneer, VeneerDraft +from mansao.veneer.validation import VeneerRejected, check +from mansao.veneer.writer import PROMPT_VERSION, VeneerUnavailable, load_prompt, write + +__all__ = [ + "PROMPT_VERSION", + "CaseVeneer", + "CharacterVeneer", + "VeneerDraft", + "VeneerRejected", + "VeneerUnavailable", + "check", + "load_prompt", + "write", +] diff --git a/apps/api/src/mansao/veneer/models.py b/apps/api/src/mansao/veneer/models.py new file mode 100644 index 0000000..7bda672 --- /dev/null +++ b/apps/api/src/mansao/veneer/models.py @@ -0,0 +1,54 @@ +"""What the model is allowed to produce. + +This schema is the contract with the model, and it is deliberately narrow: the +veneer writes *about* the cast and the house, and has no field in which to +invent a fact, move somebody, or name a culprit. Anything outside these fields +cannot reach the game because there is nowhere to put it (RN-022). +""" + +from pydantic import BaseModel, ConfigDict, Field + + +class CharacterVeneer(BaseModel): + """One character's surface. No knowledge, no whereabouts, no guilt.""" + + model_config = ConfigDict(frozen=True) + + id: str = Field(description="The character id given in the prompt, unchanged.") + role_title: str = Field(description="Their place in the house: butler, cook, nephew.") + appearance: str = Field(description="One sentence. What the player sees.") + manner: str = Field(description="One sentence. How they speak.") + + +class CaseVeneer(BaseModel): + """Prose for one case, in one locale. + + Keyed by seed, generator version, prompt version and locale: the four things + that decide what the text should be. A cached veneer whose key still matches + is still correct; when any of them moves, the text is regenerated. + """ + + model_config = ConfigDict(frozen=True) + + seed: int + generator_version: str + prompt_version: str + locale: str + model: str + scene: str = Field(description="Two or three sentences setting the house and the night.") + characters: tuple[CharacterVeneer, ...] + + def for_character(self, character_id: str) -> CharacterVeneer: + for character in self.characters: + if character.id == character_id: + return character + raise KeyError(character_id) + + +class VeneerDraft(BaseModel): + """Exactly what the model returns, before any of it is trusted.""" + + model_config = ConfigDict(frozen=True) + + scene: str + characters: tuple[CharacterVeneer, ...] diff --git a/apps/api/src/mansao/veneer/validation.py b/apps/api/src/mansao/veneer/validation.py new file mode 100644 index 0000000..1699323 --- /dev/null +++ b/apps/api/src/mansao/veneer/validation.py @@ -0,0 +1,103 @@ +"""Checks a draft has to survive before it becomes prose anyone reads. + +The model is not malicious and is still not trusted. It is a text generator +pointed at a mystery, and the failure that matters is not a jailbreak — it is a +model that quietly writes something the structure does not support, and a case +that reads as broken three questions into the interrogation. + +What can be checked mechanically is checked here. What cannot — whether the +prose is any good — is not pretended to be checked. +""" + +import re + +from mansao.domain import Case +from mansao.veneer.models import VeneerDraft + + +class VeneerRejected(ValueError): + """A draft that contradicts the case, or leaks. Discarded, never repaired.""" + + def __init__(self, check: str, detail: str) -> None: + super().__init__(f"{check}: {detail}") + self.check = check + self.detail = detail + + +CANARY = re.compile(r"CN-[0-9a-f]{8}") +MAX_FIELD_CHARS = 400 + + +def check(draft: VeneerDraft, case: Case) -> None: + """Run every check. Raises `VeneerRejected` on the first failure.""" + no_canary_leaked(draft, case) + exactly_the_cast(draft, case) + names_are_not_reassigned(draft, case) + fields_are_within_bounds(draft) + + +def _all_text(draft: VeneerDraft) -> str: + parts = [draft.scene] + for character in draft.characters: + parts += [character.role_title, character.appearance, character.manner] + return "\n".join(parts) + + +def no_canary_leaked(draft: VeneerDraft, case: Case) -> None: + """A canary in model output is a critical failure. (RN-012) + + The veneer is only shown facts that are public, so a canary here means the + context assembly is wrong — a bug upstream, not a bad sentence. The response + is discarded either way. + """ + text = _all_text(draft) + known = {fact.canary for fact in case.facts if fact.canary} + leaked = sorted(token for token in CANARY.findall(text) if token in known) + if leaked: + raise VeneerRejected("canary", f"secret fact tokens in the output: {leaked}") + if CANARY.search(text): + raise VeneerRejected("canary", "output carries a canary-shaped token") + + +def exactly_the_cast(draft: VeneerDraft, case: Case) -> None: + """No invented character, none dropped, no id renamed.""" + expected = {c.id for c in case.suspects} + received = {c.id for c in draft.characters} + if received != expected: + raise VeneerRejected( + "cast", + f"invented {sorted(received - expected)}, missing {sorted(expected - received)}", + ) + if len(draft.characters) != len(received): + raise VeneerRejected("cast", "the same id appears twice") + + +def names_are_not_reassigned(draft: VeneerDraft, case: Case) -> None: + """A character must not be described using another character's name. + + Cheap to check and worth checking: swapping two names is the kind of mistake + that survives a read-through and then contradicts every alibi in the case. + """ + for character in draft.characters: + own = case.name_of(character.id) + others = {c.name for c in case.suspects if c.id != character.id} - {own} + text = f"{character.role_title} {character.appearance} {character.manner}" + intruders = sorted(name for name in others if name in text) + if intruders: + raise VeneerRejected("names", f"{character.id} is described as {intruders}") + + +def fields_are_within_bounds(draft: VeneerDraft) -> None: + """A field that runs long is a model that started narrating the case.""" + for character in draft.characters: + for field, value in ( + ("role_title", character.role_title), + ("appearance", character.appearance), + ("manner", character.manner), + ): + if not value.strip(): + raise VeneerRejected("empty", f"{character.id}.{field} is blank") + if len(value) > MAX_FIELD_CHARS: + raise VeneerRejected("length", f"{character.id}.{field} is {len(value)} chars") + if not draft.scene.strip(): + raise VeneerRejected("empty", "scene is blank") diff --git a/apps/api/src/mansao/veneer/writer.py b/apps/api/src/mansao/veneer/writer.py new file mode 100644 index 0000000..6773509 --- /dev/null +++ b/apps/api/src/mansao/veneer/writer.py @@ -0,0 +1,146 @@ +"""Turns an approved case into prose. + +Two boundaries decide the shape of this module. + +**It takes `Case`, never `CaseWithSolution`.** The same discipline as the solver, +for a different reason: if the writer knew who the culprit was, the writing would +telegraph it. Nobody would mean to — but the guilty character would end up with +the sharper description, and the game would be over before the first question. + +**It is only shown public facts.** The veneer has no use for a restricted fact, +so it never receives one, and therefore cannot leak one. The canary check on the +output is not there to catch the model; it is there to catch us, because a canary +in this output means context assembly upstream is broken (RN-010, RN-012). + +The prompt lives in `prompts/veneer/`, versioned, never as a string literal. +""" + +import os +import re +from pathlib import Path +from typing import Any, Protocol, cast + +from mansao.domain import Case +from mansao.i18n import Catalog +from mansao.veneer.models import CaseVeneer, VeneerDraft +from mansao.veneer.validation import check + +PROMPT_VERSION = "v1" +DEFAULT_MODEL = "claude-opus-5" +MAX_TOKENS = 4000 + + +class VeneerUnavailable(RuntimeError): + """The veneer could not be produced. The case is still playable without it.""" + + +class _Parseable(Protocol): + """The slice of the SDK this module uses, so tests can supply their own.""" + + def parse(self, **kwargs: Any) -> Any: ... + + +class _Client(Protocol): + @property + def messages(self) -> _Parseable: ... + + +def prompts_dir() -> Path: + """Repository `prompts/`, or wherever `MANSAO_PROMPTS_DIR` points.""" + override = os.environ.get("MANSAO_PROMPTS_DIR") + if override: + return Path(override) + return Path(__file__).resolve().parents[5] / "prompts" + + +def load_prompt(version: str = PROMPT_VERSION) -> tuple[str, str]: + """Return the system and user halves of a versioned prompt file.""" + path = prompts_dir() / "veneer" / f"{version}.md" + if not path.exists(): + raise VeneerUnavailable(f"prompt {version} not found at {path}") + + text = path.read_text(encoding="utf-8") + sections = re.split(r"^## (System|User)\s*$", text, flags=re.MULTILINE) + parts = dict(zip(sections[1::2], sections[2::2], strict=True)) + if "System" not in parts or "User" not in parts: + raise VeneerUnavailable(f"prompt {version} is missing a System or User section") + return parts["System"].strip(), parts["User"].strip() + + +def _render_prompt(case: Case, catalog: Catalog) -> tuple[str, str]: + system, user = load_prompt() + + public = [f"- {catalog.fact(case, fact)}" for fact in case.facts if fact.scope.public] + cast = [f"- {suspect.id}: {suspect.name}" for suspect in case.suspects] + + return ( + system.format(language=catalog.label("language_name")), + user.format( + victim=case.name_of("victim"), + public_facts="\n".join(public), + cast="\n".join(cast), + ), + ) + + +def write( + case: Case, + catalog: Catalog, + *, + client: _Client | None = None, + model: str = DEFAULT_MODEL, +) -> CaseVeneer: + """Write the veneer for an approved case, or raise `VeneerUnavailable`. + + A rejected draft is discarded rather than repaired: a model that broke the + cast list once will break it differently on a patch, and a half-corrected + veneer is harder to reason about than none. + """ + if client is None: + client = _default_client() + + system, user = _render_prompt(case, catalog) + + try: + response = client.messages.parse( + model=model, + max_tokens=MAX_TOKENS, + system=system, + messages=[{"role": "user", "content": user}], + output_format=VeneerDraft, + ) + except Exception as failure: + raise VeneerUnavailable(f"the model call failed: {failure}") from failure + + if getattr(response, "stop_reason", None) == "refusal": + raise VeneerUnavailable("the model declined to write this case") + + draft = response.parsed_output + if draft is None: + raise VeneerUnavailable("the model returned no parseable draft") + + check(draft, case) + + return CaseVeneer( + seed=case.seed, + generator_version=case.generator_version, + prompt_version=PROMPT_VERSION, + locale=catalog.locale, + model=model, + scene=draft.scene, + characters=draft.characters, + ) + + +def _default_client() -> _Client: + try: + import anthropic + except ImportError as missing: # pragma: no cover - the dependency is declared + raise VeneerUnavailable("the anthropic sdk is not installed") from missing + + try: + # The SDK's own `parse` signature is narrower than the slice we use; + # the cast is the one place where that difference is acknowledged. + return cast("_Client", anthropic.Anthropic()) + except Exception as failure: + raise VeneerUnavailable(f"no usable credentials: {failure}") from failure diff --git a/apps/api/tests/test_health.py b/apps/api/tests/test_health.py index 7712fb2..6e2491e 100644 --- a/apps/api/tests/test_health.py +++ b/apps/api/tests/test_health.py @@ -10,7 +10,7 @@ def test_health_responde_ok() -> None: resposta = cliente.get("/health") assert resposta.status_code == 200 - assert resposta.json() == {"status": "ok", "versao": __version__, "ambiente": "dev"} + assert resposta.json() == {"status": "ok", "version": __version__, "environment": "dev"} def test_openapi_e_servido() -> None: diff --git a/apps/api/tests/test_veneer.py b/apps/api/tests/test_veneer.py new file mode 100644 index 0000000..ed85d22 --- /dev/null +++ b/apps/api/tests/test_veneer.py @@ -0,0 +1,218 @@ +"""Veneer tests. + +Every one of these runs without an API key. The model is replaced by a stub +that returns whatever the test needs it to return — which is the point: the +interesting cases are the drafts a real model might plausibly produce and that +must not reach a player, and waiting for a real model to produce one of them by +chance is not a test strategy. +""" + +from typing import Any + +import pytest + +from mansao.domain import Case +from mansao.generation import generate +from mansao.i18n import load +from mansao.veneer import ( + CharacterVeneer, + VeneerDraft, + VeneerRejected, + VeneerUnavailable, + check, + load_prompt, + write, +) +from mansao.veneer.writer import _render_prompt + + +class StubMessages: + def __init__(self, draft: VeneerDraft | None, stop_reason: str = "end_turn") -> None: + self.draft = draft + self.stop_reason = stop_reason + self.calls: list[dict[str, Any]] = [] + + def parse(self, **kwargs: Any) -> Any: + self.calls.append(kwargs) + return type("Response", (), {"parsed_output": self.draft, "stop_reason": self.stop_reason}) + + +class StubClient: + def __init__(self, draft: VeneerDraft | None, stop_reason: str = "end_turn") -> None: + self.messages = StubMessages(draft, stop_reason) + + +@pytest.fixture(scope="module") +def case() -> Case: + return generate(seed=42).case + + +def _good_draft(case: Case) -> VeneerDraft: + return VeneerDraft( + scene="A chuva não parou a noite toda.", + characters=tuple( + CharacterVeneer( + id=suspect.id, + role_title="mordomo", + appearance="Alto, de luvas brancas.", + manner="Fala pouco e olha para o chão.", + ) + for suspect in case.suspects + ), + ) + + +def test_the_prompt_only_ever_shows_public_facts(case: Case) -> None: + """The veneer cannot leak a restricted fact it was never given. (RN-010)""" + _, user = _render_prompt(case, load("pt-BR")) + + restricted = [f for f in case.facts if not f.scope.public] + assert restricted, "this case should have restricted facts, or the test proves nothing" + for fact in restricted: + assert fact.canary not in user + if fact.secret_key: + assert fact.secret_key not in user + + +def test_nothing_in_the_prompt_singles_out_the_culprit(case: Case) -> None: + """`write` takes `Case`, so a culprit has no parameter to arrive through. + + The stronger property is that the prompt cannot betray one either: every + suspect appears exactly once, in the cast list, and nothing else in the text + distinguishes them. A model that cannot tell them apart cannot write the + guilty one as guiltier. + """ + full = generate(seed=42) + _, user = _render_prompt(case, load("pt-BR")) + + assert full.solution.means_key not in user + assert full.solution.motive_key not in user + for suspect in case.suspects: + assert user.count(suspect.name) == 1, f"{suspect.name} appears more than in the cast" + assert user.count(suspect.id) == 1 + + +def test_a_good_draft_becomes_a_veneer(case: Case) -> None: + client = StubClient(_good_draft(case)) + + veneer = write(case, load("pt-BR"), client=client, model="stub-model") + + assert veneer.seed == case.seed + assert veneer.locale == "pt-BR" + assert veneer.prompt_version == "v1" + assert {c.id for c in veneer.characters} == {s.id for s in case.suspects} + assert veneer.for_character("sus-1").role_title == "mordomo" + + +def test_an_invented_character_is_rejected(case: Case) -> None: + draft = _good_draft(case) + tampered = draft.model_copy( + update={ + "characters": ( + *draft.characters, + CharacterVeneer(id="sus-99", role_title="jardineiro", appearance="x", manner="y"), + ) + } + ) + + with pytest.raises(VeneerRejected) as raised: + check(tampered, case) + + assert raised.value.check == "cast" + + +def test_a_dropped_character_is_rejected(case: Case) -> None: + draft = _good_draft(case) + tampered = draft.model_copy(update={"characters": draft.characters[:-1]}) + + with pytest.raises(VeneerRejected, match="missing"): + check(tampered, case) + + +def test_a_leaked_canary_is_rejected(case: Case) -> None: + """RN-012. A canary here means context assembly upstream is broken.""" + secret = next(f for f in case.facts if not f.scope.public and f.canary) + draft = _good_draft(case) + tampered = draft.model_copy(update={"scene": f"A casa dormia. {secret.canary}"}) + + with pytest.raises(VeneerRejected) as raised: + check(tampered, case) + + assert raised.value.check == "canary" + assert secret.canary in raised.value.detail + + +def test_a_swapped_name_is_rejected(case: Case) -> None: + """Describing one suspect as another contradicts every alibi in the case.""" + first, second = case.suspects[0], case.suspects[1] + draft = _good_draft(case) + tampered = draft.model_copy( + update={ + "characters": ( + draft.characters[0].model_copy( + update={"appearance": f"Parece muito com {second.name}."} + ), + *draft.characters[1:], + ) + } + ) + + with pytest.raises(VeneerRejected, match="names"): + check(tampered, case) + + assert first.id + + +def test_a_blank_field_is_rejected(case: Case) -> None: + draft = _good_draft(case) + tampered = draft.model_copy( + update={ + "characters": ( + draft.characters[0].model_copy(update={"manner": " "}), + *draft.characters[1:], + ) + } + ) + + with pytest.raises(VeneerRejected, match="empty"): + check(tampered, case) + + +def test_a_refusal_is_not_a_crash(case: Case) -> None: + client = StubClient(None, stop_reason="refusal") + + with pytest.raises(VeneerUnavailable, match="declined"): + write(case, load("pt-BR"), client=client) + + +def test_a_transport_failure_becomes_veneer_unavailable(case: Case) -> None: + class Exploding: + def parse(self, **kwargs: Any) -> Any: + raise ConnectionError("no route to host") + + client = type("C", (), {"messages": Exploding()})() + + with pytest.raises(VeneerUnavailable, match="model call failed"): + write(case, load("pt-BR"), client=client) + + +def test_the_prompt_file_is_the_source_of_truth() -> None: + """Prompts live in `prompts/`, versioned, never as a string literal.""" + system, user = load_prompt("v1") + + assert "{language}" in system + assert "{public_facts}" in user + assert "Never state or imply who killed the victim" in system + + +def test_an_unknown_prompt_version_fails_loudly() -> None: + with pytest.raises(VeneerUnavailable, match="not found"): + load_prompt("v999") + + +@pytest.mark.parametrize("locale", ["pt-BR", "en"]) +def test_the_prompt_asks_for_the_locale_language(case: Case, locale: str) -> None: + catalog = load(locale) + system, _ = _render_prompt(case, catalog) + + assert catalog.label("language_name") in system diff --git a/apps/api/uv.lock b/apps/api/uv.lock index db202be..fda8129 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -24,6 +24,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "anthropic" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "docstring-parser" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/1a/b5af41cc1fa14da277ec20ca5554dd2fcbc09b8523ac59b7a97fbb88e452/anthropic-1.2.0.tar.gz", hash = "sha256:12f8eedee7b7fb5685837b1371b7bfae1b281703f62355f4632598ec2fc53b34", size = 1137443, upload-time = "2026-08-27T20:29:12.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/78/3f8b52708b03309e511990700bb8d0ec7a0c9db3d2a1e0d1c3ca417a4604/anthropic-1.2.0-py3-none-any.whl", hash = "sha256:b60642b3e3cd6b8e3e328a2d3f2863ad2b6e743f1037e42cc0143f7df99f63c6", size = 1289535, upload-time = "2026-08-27T20:29:11.01Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -127,6 +145,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -165,6 +192,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -209,6 +249,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.19" @@ -227,6 +292,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, +] + [[package]] name = "librt" version = "0.15.0" @@ -318,6 +433,7 @@ name = "mansao-api" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "anthropic" }, { name = "fastapi" }, { name = "pydantic-settings" }, { name = "uvicorn", extra = ["standard"] }, @@ -333,6 +449,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=1.2.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, @@ -607,6 +724,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "starlette" version = "1.6.0" @@ -619,6 +745,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" diff --git a/infra/compose/docker-compose.yml b/infra/compose/docker-compose.yml index 21f1c94..7237c8b 100644 --- a/infra/compose/docker-compose.yml +++ b/infra/compose/docker-compose.yml @@ -34,7 +34,7 @@ services: build: context: ../../apps/api environment: - MANSAO_AMBIENTE: dev + MANSAO_ENVIRONMENT: dev MANSAO_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-mansao}:${POSTGRES_PASSWORD:-mansao}@db:5432/${POSTGRES_DB:-mansao} MANSAO_REDIS_URL: redis://redis:6379/0 ports: diff --git a/prompts/veneer/v1.md b/prompts/veneer/v1.md new file mode 100644 index 0000000..04be866 --- /dev/null +++ b/prompts/veneer/v1.md @@ -0,0 +1,51 @@ +# Veneer prompt v1 + +Turns an approved case structure into the prose a player reads. Versioned +because changing it changes the output distribution: bump the version, rerun the +evals, keep the old file (ADR-0004). + +The prompt never receives the solution. It cannot leak what it was not given. + +--- + +## System + +You write character sheets for a murder mystery set in a Brazilian manor house +in the 1930s. You are given a cast and what each person did that night. You +write who they are. + +Write in {language}. + +Rules you do not break: + +- Use exactly the character ids you are given. Do not invent a character, do not + drop one, do not rename an id. +- Keep the names you are given. They are the characters' names; you are giving + them a role in the house, a face and a way of speaking. +- Never state or imply who killed the victim. You have not been told, and + guessing would ruin the case. If a detail would only make sense if you knew + the culprit, leave it out. +- Every suspect must read as someone with something to hide, because every one + of them does. Do not make the person with the most facts sound guiltier than + the others. +- Do not invent physical evidence, injuries, weapons, or anything about how the + victim died. Do not place anyone in a room at a time you were not told about. +- Keep each field short: a role title of two or three words, an appearance of + one sentence, a manner of speaking of one sentence. + +The scene description sets the house and the night in two or three sentences. It +mentions the victim and the mood, never a suspicion. + +## User + +Victim: {victim} + +The night, as the house knows it: + +{public_facts} + +Cast: + +{cast} + +Write the veneer for every character id listed above. From 4577955b1f425d458147d5a76cb05ebac802cff3 Mon Sep 17 00:00:00 2001 From: Madeuss Date: Sun, 30 Aug 2026 18:34:33 -0300 Subject: [PATCH 2/3] feat(api): default the veneer to haiku MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The veneer is short, structured, and its failure modes — an invented character, a swapped name, a leaked canary — are caught by validation rather than by model quality. The prompt measures ~400 input tokens and the reply ~400 output, which puts Haiku 4.5 at roughly US$ 0.0025 per case: about 400 cases per dollar. Opus would be ~5x that on paper and more in practice, because adaptive thinking is on by default there and thinking tokens bill as output. That headroom is better spent in phase 2, where six NPCs each answer a turn against the R$ 0,50 per-match budget. Env-switchable through MANSAO_VENEER_MODEL, so comparing the two on the same seed costs about a cent. --- .env.example | 2 +- apps/api/src/mansao/config.py | 2 +- apps/api/src/mansao/veneer/writer.py | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index ba5a9a1..d341c9c 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,4 @@ MANSAO_REDIS_URL=redis://localhost:6379/0 # ANTHROPIC_API_KEY= # Qual modelo escreve o verniz. É o botão que move o custo por caso. -MANSAO_VENEER_MODEL=claude-opus-5 +MANSAO_VENEER_MODEL=claude-haiku-4-5 diff --git a/apps/api/src/mansao/config.py b/apps/api/src/mansao/config.py index 5d1eaf8..cc68edf 100644 --- a/apps/api/src/mansao/config.py +++ b/apps/api/src/mansao/config.py @@ -13,7 +13,7 @@ class Settings(BaseSettings): environment: Environment = "dev" database_url: str = "postgresql+psycopg://mansao:mansao@localhost:5433/mansao" redis_url: str = "redis://localhost:6379/0" - veneer_model: str = "claude-opus-5" + 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.""" diff --git a/apps/api/src/mansao/veneer/writer.py b/apps/api/src/mansao/veneer/writer.py index 6773509..729ffd5 100644 --- a/apps/api/src/mansao/veneer/writer.py +++ b/apps/api/src/mansao/veneer/writer.py @@ -26,7 +26,10 @@ from mansao.veneer.validation import check PROMPT_VERSION = "v1" -DEFAULT_MODEL = "claude-opus-5" +# Haiku: the veneer is short, structured, and its failure modes are caught by +# validation rather than by model quality. ~US$ 0.0025 per case against a +# R$ 0,50 per-match budget that phase 2 will spend on six NPCs and a turn each. +DEFAULT_MODEL = "claude-haiku-4-5" MAX_TOKENS = 4000 From 2e5d7b7a056011361508500b983051efa27641de Mon Sep 17 00:00:00 2001 From: Madeuss Date: Sun, 30 Aug 2026 18:53:28 -0300 Subject: [PATCH 3/3] chore!: rename the project to firenze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository was firenze, the product was Mansão, and the Python package was mansao. Three names for one thing, on a public repository where the first one a visitor sees is the repository's. Now they are the same word, and the plan drops its "(working title)" marker because the name is settled. Mechanical throughout: package, imports, entry point, env prefix (MANSAO_ -> FIRENZE_), compose project, database user and name, API title, CLI command, and every path cited in the documents and ADRs. Nothing in the domain changed. The manor is still the only setting the generator knows how to build — whether the game follows the name to Italy is a separate question about content, not about what anything is called. Local containers and the pgvector volume were provisioned under the old project name. `docker compose -p mansao down -v` clears them; `make dev` rebuilds under the new one. No data is lost that matters — no case is persisted yet, and there are no migrations. --- .env.example | 18 +++--- CLAUDE.md | 2 +- Makefile | 6 +- README.md | 12 ++-- apps/api/openapi.json | 2 +- apps/api/pyproject.toml | 8 +-- apps/api/scripts/dump_openapi.py | 2 +- apps/api/src/{mansao => firenze}/__init__.py | 2 +- apps/api/src/{mansao => firenze}/cli.py | 14 ++-- apps/api/src/{mansao => firenze}/config.py | 6 +- .../{mansao => firenze}/domain/__init__.py | 2 +- .../src/{mansao => firenze}/domain/models.py | 0 .../generation/__init__.py | 6 +- .../generation/generator.py | 6 +- .../{mansao => firenze}/generation/solver.py | 2 +- .../generation/validation.py | 2 +- .../src/{mansao => firenze}/i18n/__init__.py | 2 +- .../src/{mansao => firenze}/i18n/catalog.py | 2 +- .../{mansao => firenze}/i18n/messages/en.json | 0 .../i18n/messages/pt-BR.json | 0 apps/api/src/{mansao => firenze}/main.py | 6 +- .../{mansao => firenze}/veneer/__init__.py | 6 +- .../src/{mansao => firenze}/veneer/models.py | 0 .../{mansao => firenze}/veneer/validation.py | 4 +- .../src/{mansao => firenze}/veneer/writer.py | 12 ++-- apps/api/tests/test_cli.py | 2 +- apps/api/tests/test_generation.py | 8 +-- apps/api/tests/test_health.py | 6 +- apps/api/tests/test_i18n.py | 8 +-- apps/api/tests/test_veneer.py | 10 +-- apps/api/uv.lock | 64 +++++++++---------- docs/00-plano-de-projeto.md | 14 ++-- ...rministic-generation-with-an-llm-veneer.md | 2 +- .../0005-locale-is-a-property-of-the-match.md | 4 +- infra/compose/docker-compose.yml | 16 ++--- infra/compose/init/01-extensions.sql | 2 +- 36 files changed, 129 insertions(+), 129 deletions(-) rename apps/api/src/{mansao => firenze}/__init__.py (51%) rename apps/api/src/{mansao => firenze}/cli.py (89%) rename apps/api/src/{mansao => firenze}/config.py (63%) rename apps/api/src/{mansao => firenze}/domain/__init__.py (89%) rename apps/api/src/{mansao => firenze}/domain/models.py (100%) rename apps/api/src/{mansao => firenze}/generation/__init__.py (50%) rename apps/api/src/{mansao => firenze}/generation/generator.py (98%) rename apps/api/src/{mansao => firenze}/generation/solver.py (98%) rename apps/api/src/{mansao => firenze}/generation/validation.py (97%) rename apps/api/src/{mansao => firenze}/i18n/__init__.py (89%) rename apps/api/src/{mansao => firenze}/i18n/catalog.py (99%) rename apps/api/src/{mansao => firenze}/i18n/messages/en.json (100%) rename apps/api/src/{mansao => firenze}/i18n/messages/pt-BR.json (100%) rename apps/api/src/{mansao => firenze}/main.py (89%) rename apps/api/src/{mansao => firenze}/veneer/__init__.py (54%) rename apps/api/src/{mansao => firenze}/veneer/models.py (100%) rename apps/api/src/{mansao => firenze}/veneer/validation.py (98%) rename apps/api/src/{mansao => firenze}/veneer/writer.py (94%) diff --git a/.env.example b/.env.example index d341c9c..facb774 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,18 @@ # Copie para .env. O .env não é commitado. # Compose (Postgres local em localhost:5433) -POSTGRES_USER=mansao -POSTGRES_PASSWORD=mansao -POSTGRES_DB=mansao +POSTGRES_USER=firenze +POSTGRES_PASSWORD=firenze +POSTGRES_DB=firenze -# API — prefixo MANSAO_ (ver apps/api/src/mansao/config.py) -MANSAO_ENVIRONMENT=dev -MANSAO_DATABASE_URL=postgresql+psycopg://mansao:mansao@localhost:5433/mansao -MANSAO_REDIS_URL=redis://localhost:6379/0 +# API — prefixo FIRENZE_ (ver apps/api/src/firenze/config.py) +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 MANSAO_. +# O SDK lê ANTHROPIC_API_KEY sozinho — sem prefixo FIRENZE_. # ANTHROPIC_API_KEY= # Qual modelo escreve o verniz. É o botão que move o custo por caso. -MANSAO_VENEER_MODEL=claude-haiku-4-5 +FIRENZE_VENEER_MODEL=claude-haiku-4-5 diff --git a/CLAUDE.md b/CLAUDE.md index 92c5186..27b1b75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,4 @@ -# Mansão +# Firenze Jogo de mistério investigativo com NPCs agênticos. Next.js (front) + FastAPI/LangGraph (core de IA) + Postgres com pgvector. diff --git a/Makefile b/Makefile index 8e3d755..6ea27b4 100644 --- a/Makefile +++ b/Makefile @@ -18,16 +18,16 @@ logs: ## segue os logs do compose $(COMPOSE) logs -f psql: ## abre psql no banco local - $(COMPOSE) exec db psql -U mansao -d mansao + $(COMPOSE) exec db psql -U firenze -d firenze install: ## sincroniza o venv de apps/api a partir do uv.lock cd $(API) && uv sync --extra dev api: ## roda a API local com reload (sem container) - cd $(API) && uv run uvicorn mansao.main:app --reload --port 8000 + cd $(API) && uv run uvicorn firenze.main:app --reload --port 8000 case: ## gera um caso (make case SEED=42 [LOCALE=en] [REVEAL=1] [VENEER=1]) - cd $(API) && uv run mansao generate --seed $(or $(SEED),1) --locale $(or $(LOCALE),pt-BR) $(if $(REVEAL),--reveal,) $(if $(VENEER),--veneer,) + cd $(API) && uv run firenze generate --seed $(or $(SEED),1) --locale $(or $(LOCALE),pt-BR) $(if $(REVEAL),--reveal,) $(if $(VENEER),--veneer,) openapi: ## regenera apps/api/openapi.json cd $(API) && uv run python scripts/dump_openapi.py diff --git a/README.md b/README.md index f5d6b74..9256d09 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Mansão +# Firenze An investigative mystery game where the suspects are LLM-driven NPCs. You question them in free text, confront them with evidence, and name a culprit. @@ -70,7 +70,7 @@ Roughly half of the mysteries you get by sampling constraints at random are unsolvable, and they *look* fine until a player wastes an hour on one. So a case is not publishable until an automated solver proves a deduction path exists. -[`generation/solver.py`](apps/api/src/mansao/generation/solver.py) takes `Case` +[`generation/solver.py`](apps/api/src/firenze/generation/solver.py) takes `Case` and never `CaseWithSolution` — it cannot read the answer, and the type signature is what guarantees that rather than the discipline of whoever writes the next function. It starts from the public facts, adds everything a suspect would @@ -83,7 +83,7 @@ regenerated. "Do not reveal the solution" in a system prompt is a wish. The solution is a separate entity from the case, so the function that assembles an NPC's context takes a type that has no path to the culprit -([`domain/models.py`](apps/api/src/mansao/domain/models.py)). +([`domain/models.py`](apps/api/src/firenze/domain/models.py)). ```mermaid flowchart LR @@ -138,9 +138,9 @@ curl localhost:8000/health | Path | What lives there | |---|---| -| [`apps/api/src/mansao/domain/`](apps/api/src/mansao/domain/) | Entities. Structure only — no prose, no rendered sentence | -| [`apps/api/src/mansao/generation/`](apps/api/src/mansao/generation/) | Generator, solver, and the invariant checks | -| [`apps/api/src/mansao/i18n/`](apps/api/src/mansao/i18n/) | Message catalogs. Grammar lives here, not in the domain | +| [`apps/api/src/firenze/domain/`](apps/api/src/firenze/domain/) | Entities. Structure only — no prose, no rendered sentence | +| [`apps/api/src/firenze/generation/`](apps/api/src/firenze/generation/) | Generator, solver, and the invariant checks | +| [`apps/api/src/firenze/i18n/`](apps/api/src/firenze/i18n/) | Message catalogs. Grammar lives here, not in the domain | | [`docs/adr/`](docs/adr/) | Architecture decisions, with their downsides written down | | [`infra/compose/`](infra/compose/) | Local Postgres with pgvector, Redis, API | diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 5138311..9660350 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -36,7 +36,7 @@ "info": { "description": "Owns the whole deterministic domain and every call to a model (ADR-0003). The front end never talks to the model provider or to the database.", "summary": "AI core and domain of the mystery game.", - "title": "Mansão API", + "title": "Firenze API", "version": "0.1.0" }, "openapi": "3.1.0", diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index b511b12..ac399ce 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "mansao-api" +name = "firenze-api" version = "0.1.0" -description = "Core de IA e domínio do Mansão" +description = "Core de IA e domínio do Firenze" requires-python = ">=3.13" dependencies = [ "fastapi>=0.115", @@ -11,7 +11,7 @@ dependencies = [ ] [project.scripts] -mansao = "mansao.cli:main" +firenze = "firenze.cli:main" [project.optional-dependencies] dev = [ @@ -26,7 +26,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/mansao"] +packages = ["src/firenze"] [tool.ruff] line-length = 100 diff --git a/apps/api/scripts/dump_openapi.py b/apps/api/scripts/dump_openapi.py index 072807c..be8bd77 100644 --- a/apps/api/scripts/dump_openapi.py +++ b/apps/api/scripts/dump_openapi.py @@ -8,7 +8,7 @@ import json from pathlib import Path -from mansao.main import app +from firenze.main import app DESTINO = Path(__file__).resolve().parents[1] / "openapi.json" diff --git a/apps/api/src/mansao/__init__.py b/apps/api/src/firenze/__init__.py similarity index 51% rename from apps/api/src/mansao/__init__.py rename to apps/api/src/firenze/__init__.py index d7e7fd5..d79c7af 100644 --- a/apps/api/src/mansao/__init__.py +++ b/apps/api/src/firenze/__init__.py @@ -1,3 +1,3 @@ from importlib.metadata import version -__version__ = version("mansao-api") +__version__ = version("firenze-api") diff --git a/apps/api/src/mansao/cli.py b/apps/api/src/firenze/cli.py similarity index 89% rename from apps/api/src/mansao/cli.py rename to apps/api/src/firenze/cli.py index 33a89e0..eb0f2bd 100644 --- a/apps/api/src/mansao/cli.py +++ b/apps/api/src/firenze/cli.py @@ -1,6 +1,6 @@ """Case generator CLI. -`mansao generate --seed 42` prints the case as a player would receive it. The +`firenze generate --seed 42` prints the case as a player would receive it. The solution only shows with `--reveal`, and that is not decoration: if printing the culprit were the default, running the command while developing would spoil every case you meant to play. @@ -14,11 +14,11 @@ import sys from collections.abc import Sequence -from mansao.config import settings -from mansao.domain import CaseWithSolution, FactKind, Role -from mansao.generation import UnsolvableCase, generate, solve -from mansao.i18n import DEFAULT_LOCALE, Catalog, UnknownLocale, available_locales, load -from mansao.veneer import CaseVeneer, VeneerRejected, VeneerUnavailable, write +from firenze.config import settings +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.veneer import CaseVeneer, VeneerRejected, VeneerUnavailable, write def _briefing( @@ -80,7 +80,7 @@ def _briefing( def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(prog="mansao", description="Mansão case generator.") + parser = argparse.ArgumentParser(prog="firenze", description="Firenze case generator.") sub = parser.add_subparsers(dest="command", required=True) gen = sub.add_parser("generate", help="generate a case from a seed") diff --git a/apps/api/src/mansao/config.py b/apps/api/src/firenze/config.py similarity index 63% rename from apps/api/src/mansao/config.py rename to apps/api/src/firenze/config.py index cc68edf..aceb17a 100644 --- a/apps/api/src/mansao/config.py +++ b/apps/api/src/firenze/config.py @@ -6,12 +6,12 @@ class Settings(BaseSettings): - """Process configuration. MANSAO_ prefix so nothing collides.""" + """Process configuration. FIRENZE_ prefix so nothing collides.""" - model_config = SettingsConfigDict(env_prefix="MANSAO_", env_file=".env", extra="ignore") + model_config = SettingsConfigDict(env_prefix="FIRENZE_", env_file=".env", extra="ignore") environment: Environment = "dev" - database_url: str = "postgresql+psycopg://mansao:mansao@localhost:5433/mansao" + 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 diff --git a/apps/api/src/mansao/domain/__init__.py b/apps/api/src/firenze/domain/__init__.py similarity index 89% rename from apps/api/src/mansao/domain/__init__.py rename to apps/api/src/firenze/domain/__init__.py index 9a5ae3d..3e570df 100644 --- a/apps/api/src/mansao/domain/__init__.py +++ b/apps/api/src/firenze/domain/__init__.py @@ -1,6 +1,6 @@ """Domain model. Vocabulary in docs/01-dominio.md.""" -from mansao.domain.models import ( +from firenze.domain.models import ( Case, CaseWithSolution, Character, diff --git a/apps/api/src/mansao/domain/models.py b/apps/api/src/firenze/domain/models.py similarity index 100% rename from apps/api/src/mansao/domain/models.py rename to apps/api/src/firenze/domain/models.py diff --git a/apps/api/src/mansao/generation/__init__.py b/apps/api/src/firenze/generation/__init__.py similarity index 50% rename from apps/api/src/mansao/generation/__init__.py rename to apps/api/src/firenze/generation/__init__.py index d10eb79..1ca914b 100644 --- a/apps/api/src/mansao/generation/__init__.py +++ b/apps/api/src/firenze/generation/__init__.py @@ -1,8 +1,8 @@ """Case generation, validation and the deducibility gate.""" -from mansao.generation.generator import GENERATOR_VERSION, UnsolvableCase, generate -from mansao.generation.solver import SolverResult, solve -from mansao.generation.validation import InvalidCase, validate +from firenze.generation.generator import GENERATOR_VERSION, UnsolvableCase, generate +from firenze.generation.solver import SolverResult, solve +from firenze.generation.validation import InvalidCase, validate __all__ = [ "GENERATOR_VERSION", diff --git a/apps/api/src/mansao/generation/generator.py b/apps/api/src/firenze/generation/generator.py similarity index 98% rename from apps/api/src/mansao/generation/generator.py rename to apps/api/src/firenze/generation/generator.py index 929a1e5..693490b 100644 --- a/apps/api/src/mansao/generation/generator.py +++ b/apps/api/src/firenze/generation/generator.py @@ -13,7 +13,7 @@ import random from itertools import count -from mansao.domain import ( +from firenze.domain import ( Case, CaseWithSolution, Character, @@ -23,8 +23,8 @@ Scope, Solution, ) -from mansao.generation.solver import solve -from mansao.generation.validation import validate +from firenze.generation.solver import solve +from firenze.generation.validation import validate GENERATOR_VERSION = "2" diff --git a/apps/api/src/mansao/generation/solver.py b/apps/api/src/firenze/generation/solver.py similarity index 98% rename from apps/api/src/mansao/generation/solver.py rename to apps/api/src/firenze/generation/solver.py index 9da43d3..f1837a4 100644 --- a/apps/api/src/mansao/generation/solver.py +++ b/apps/api/src/firenze/generation/solver.py @@ -15,7 +15,7 @@ from pydantic import BaseModel, ConfigDict -from mansao.domain import Case, Fact, FactKind +from firenze.domain import Case, Fact, FactKind class SolverResult(BaseModel): diff --git a/apps/api/src/mansao/generation/validation.py b/apps/api/src/firenze/generation/validation.py similarity index 97% rename from apps/api/src/mansao/generation/validation.py rename to apps/api/src/firenze/generation/validation.py index 80d45de..8cf3847 100644 --- a/apps/api/src/mansao/generation/validation.py +++ b/apps/api/src/firenze/generation/validation.py @@ -5,7 +5,7 @@ something when the generator changes, or when a case arrives from elsewhere. """ -from mansao.domain import CaseWithSolution, FactKind, Role +from firenze.domain import CaseWithSolution, FactKind, Role class InvalidCase(ValueError): diff --git a/apps/api/src/mansao/i18n/__init__.py b/apps/api/src/firenze/i18n/__init__.py similarity index 89% rename from apps/api/src/mansao/i18n/__init__.py rename to apps/api/src/firenze/i18n/__init__.py index 40c83e5..3e97228 100644 --- a/apps/api/src/mansao/i18n/__init__.py +++ b/apps/api/src/firenze/i18n/__init__.py @@ -1,6 +1,6 @@ """Locale catalogs: structure in, prose out.""" -from mansao.i18n.catalog import ( +from firenze.i18n.catalog import ( DEFAULT_LOCALE, Catalog, MissingMessage, diff --git a/apps/api/src/mansao/i18n/catalog.py b/apps/api/src/firenze/i18n/catalog.py similarity index 99% rename from apps/api/src/mansao/i18n/catalog.py rename to apps/api/src/firenze/i18n/catalog.py index 1645b2c..cd0b3bb 100644 --- a/apps/api/src/mansao/i18n/catalog.py +++ b/apps/api/src/firenze/i18n/catalog.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Any -from mansao.domain import Case, Fact +from firenze.domain import Case, Fact MESSAGES_DIR = Path(__file__).parent / "messages" DEFAULT_LOCALE = "pt-BR" diff --git a/apps/api/src/mansao/i18n/messages/en.json b/apps/api/src/firenze/i18n/messages/en.json similarity index 100% rename from apps/api/src/mansao/i18n/messages/en.json rename to apps/api/src/firenze/i18n/messages/en.json diff --git a/apps/api/src/mansao/i18n/messages/pt-BR.json b/apps/api/src/firenze/i18n/messages/pt-BR.json similarity index 100% rename from apps/api/src/mansao/i18n/messages/pt-BR.json rename to apps/api/src/firenze/i18n/messages/pt-BR.json diff --git a/apps/api/src/mansao/main.py b/apps/api/src/firenze/main.py similarity index 89% rename from apps/api/src/mansao/main.py rename to apps/api/src/firenze/main.py index 9586b10..8b62212 100644 --- a/apps/api/src/mansao/main.py +++ b/apps/api/src/firenze/main.py @@ -3,8 +3,8 @@ from fastapi import FastAPI from pydantic import BaseModel -from mansao import __version__ -from mansao.config import Environment, settings +from firenze import __version__ +from firenze.config import Environment, settings class Health(BaseModel): @@ -17,7 +17,7 @@ class Health(BaseModel): def create_app() -> FastAPI: app = FastAPI( - title="Mansão API", + title="Firenze API", version=__version__, summary="AI core and domain of the mystery game.", description=( diff --git a/apps/api/src/mansao/veneer/__init__.py b/apps/api/src/firenze/veneer/__init__.py similarity index 54% rename from apps/api/src/mansao/veneer/__init__.py rename to apps/api/src/firenze/veneer/__init__.py index 125d6da..8b622ce 100644 --- a/apps/api/src/mansao/veneer/__init__.py +++ b/apps/api/src/firenze/veneer/__init__.py @@ -1,8 +1,8 @@ """Prose over an approved structure. The model writes; it decides nothing.""" -from mansao.veneer.models import CaseVeneer, CharacterVeneer, VeneerDraft -from mansao.veneer.validation import VeneerRejected, check -from mansao.veneer.writer import PROMPT_VERSION, VeneerUnavailable, load_prompt, write +from firenze.veneer.models import CaseVeneer, CharacterVeneer, VeneerDraft +from firenze.veneer.validation import VeneerRejected, check +from firenze.veneer.writer import PROMPT_VERSION, VeneerUnavailable, load_prompt, write __all__ = [ "PROMPT_VERSION", diff --git a/apps/api/src/mansao/veneer/models.py b/apps/api/src/firenze/veneer/models.py similarity index 100% rename from apps/api/src/mansao/veneer/models.py rename to apps/api/src/firenze/veneer/models.py diff --git a/apps/api/src/mansao/veneer/validation.py b/apps/api/src/firenze/veneer/validation.py similarity index 98% rename from apps/api/src/mansao/veneer/validation.py rename to apps/api/src/firenze/veneer/validation.py index 1699323..ea0ccd7 100644 --- a/apps/api/src/mansao/veneer/validation.py +++ b/apps/api/src/firenze/veneer/validation.py @@ -11,8 +11,8 @@ import re -from mansao.domain import Case -from mansao.veneer.models import VeneerDraft +from firenze.domain import Case +from firenze.veneer.models import VeneerDraft class VeneerRejected(ValueError): diff --git a/apps/api/src/mansao/veneer/writer.py b/apps/api/src/firenze/veneer/writer.py similarity index 94% rename from apps/api/src/mansao/veneer/writer.py rename to apps/api/src/firenze/veneer/writer.py index 729ffd5..259c68e 100644 --- a/apps/api/src/mansao/veneer/writer.py +++ b/apps/api/src/firenze/veneer/writer.py @@ -20,10 +20,10 @@ from pathlib import Path from typing import Any, Protocol, cast -from mansao.domain import Case -from mansao.i18n import Catalog -from mansao.veneer.models import CaseVeneer, VeneerDraft -from mansao.veneer.validation import check +from firenze.domain import Case +from firenze.i18n import Catalog +from firenze.veneer.models import CaseVeneer, VeneerDraft +from firenze.veneer.validation import check PROMPT_VERSION = "v1" # Haiku: the veneer is short, structured, and its failure modes are caught by @@ -49,8 +49,8 @@ def messages(self) -> _Parseable: ... def prompts_dir() -> Path: - """Repository `prompts/`, or wherever `MANSAO_PROMPTS_DIR` points.""" - override = os.environ.get("MANSAO_PROMPTS_DIR") + """Repository `prompts/`, or wherever `FIRENZE_PROMPTS_DIR` points.""" + override = os.environ.get("FIRENZE_PROMPTS_DIR") if override: return Path(override) return Path(__file__).resolve().parents[5] / "prompts" diff --git a/apps/api/tests/test_cli.py b/apps/api/tests/test_cli.py index 0cb42f7..e01fc73 100644 --- a/apps/api/tests/test_cli.py +++ b/apps/api/tests/test_cli.py @@ -1,6 +1,6 @@ import pytest -from mansao.cli import main +from firenze.cli import main Capture = pytest.CaptureFixture[str] diff --git a/apps/api/tests/test_generation.py b/apps/api/tests/test_generation.py index 1d54c71..4e4321e 100644 --- a/apps/api/tests/test_generation.py +++ b/apps/api/tests/test_generation.py @@ -6,10 +6,10 @@ import pytest -from mansao.domain import CaseWithSolution, FactKind, Role -from mansao.generation import InvalidCase, generate, solve, validate -from mansao.generation.solver import reachable_facts -from mansao.generation.validation import rn_004_no_overlap +from firenze.domain import CaseWithSolution, FactKind, Role +from firenze.generation import InvalidCase, generate, solve, validate +from firenze.generation.solver import reachable_facts +from firenze.generation.validation import rn_004_no_overlap SEEDS = range(1, 41) diff --git a/apps/api/tests/test_health.py b/apps/api/tests/test_health.py index 6e2491e..378031f 100644 --- a/apps/api/tests/test_health.py +++ b/apps/api/tests/test_health.py @@ -1,7 +1,7 @@ from fastapi.testclient import TestClient -from mansao import __version__ -from mansao.main import app +from firenze import __version__ +from firenze.main import app cliente = TestClient(app) @@ -16,5 +16,5 @@ def test_health_responde_ok() -> None: def test_openapi_e_servido() -> None: esquema = cliente.get("/openapi.json").json() - assert esquema["info"]["title"] == "Mansão API" + assert esquema["info"]["title"] == "Firenze API" assert "/health" in esquema["paths"] diff --git a/apps/api/tests/test_i18n.py b/apps/api/tests/test_i18n.py index 7b7c2f9..c8f0f26 100644 --- a/apps/api/tests/test_i18n.py +++ b/apps/api/tests/test_i18n.py @@ -7,10 +7,10 @@ import pytest -from mansao.domain import FactKind -from mansao.generation import generate -from mansao.generation.generator import MEANS_KEYS, MOTIVE_KEYS, ROOMS, SECRET_KEYS -from mansao.i18n import UnknownLocale, available_locales, load +from firenze.domain import FactKind +from firenze.generation import generate +from firenze.generation.generator import MEANS_KEYS, MOTIVE_KEYS, ROOMS, SECRET_KEYS +from firenze.i18n import UnknownLocale, available_locales, load LOCALES = available_locales() diff --git a/apps/api/tests/test_veneer.py b/apps/api/tests/test_veneer.py index ed85d22..309edf7 100644 --- a/apps/api/tests/test_veneer.py +++ b/apps/api/tests/test_veneer.py @@ -11,10 +11,10 @@ import pytest -from mansao.domain import Case -from mansao.generation import generate -from mansao.i18n import load -from mansao.veneer import ( +from firenze.domain import Case +from firenze.generation import generate +from firenze.i18n import load +from firenze.veneer import ( CharacterVeneer, VeneerDraft, VeneerRejected, @@ -23,7 +23,7 @@ load_prompt, write, ) -from mansao.veneer.writer import _render_prompt +from firenze.veneer.writer import _render_prompt class StubMessages: diff --git a/apps/api/uv.lock b/apps/api/uv.lock index fda8129..a32f4b8 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -170,6 +170,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, ] +[[package]] +name = "firenze-api" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "anthropic" }, + { name = "fastapi" }, + { name = "pydantic-settings" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=1.2.0" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, + { name = "pydantic-settings", specifier = ">=2.7" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, +] +provides-extras = ["dev"] + [[package]] name = "h11" version = "0.16.0" @@ -428,38 +460,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, ] -[[package]] -name = "mansao-api" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "anthropic" }, - { name = "fastapi" }, - { name = "pydantic-settings" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.optional-dependencies] -dev = [ - { name = "httpx" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anthropic", specifier = ">=1.2.0" }, - { name = "fastapi", specifier = ">=0.115" }, - { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.14" }, - { name = "pydantic-settings", specifier = ">=2.7" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, -] -provides-extras = ["dev"] - [[package]] name = "mypy" version = "2.3.1" diff --git a/docs/00-plano-de-projeto.md b/docs/00-plano-de-projeto.md index 657db71..990444c 100644 --- a/docs/00-plano-de-projeto.md +++ b/docs/00-plano-de-projeto.md @@ -1,4 +1,4 @@ -# Plano de Projeto — Mansão (working title) +# Plano de Projeto — Firenze > Jogo de mistério investigativo com NPCs agênticos. > Documento vivo. Commitar em `docs/00-plano-de-projeto.md` e atualizar a cada fase. @@ -32,7 +32,7 @@ com o código que ela descreve. Diagrama que não está em Git apodrece. ### Estrutura (monorepo) ``` -mansao/ +firenze/ ├── README.md # o que é, como rodar em 5 min ├── CONTRIBUTING.md # convenções (mesmo solo — disciplina) ├── CHANGELOG.md # gerado por conventional commits @@ -197,14 +197,14 @@ Níveis 1 e 2 bastam. Nível 3 só para o subsistema de agentes. ```mermaid C4Context - title Contexto — Mansão + title Contexto — Firenze Person(jogador, "Jogador") - System(mansao, "Mansão", "Jogo de mistério com NPCs agênticos") + System(firenze, "Firenze", "Jogo de mistério com NPCs agênticos") System_Ext(llm, "Provedor LLM", "Inferência dos NPCs") System_Ext(obs, "Langfuse", "Tracing e avaliação") - Rel(jogador, mansao, "Interroga, confronta, acusa") - Rel(mansao, llm, "Completions estruturadas") - Rel(mansao, obs, "Traces, custo, latência") + Rel(jogador, firenze, "Interroga, confronta, acusa") + Rel(firenze, llm, "Completions estruturadas") + Rel(firenze, obs, "Traces, custo, latência") ``` ### 4.3 Diagramas de sequência diff --git a/docs/adr/0004-deterministic-generation-with-an-llm-veneer.md b/docs/adr/0004-deterministic-generation-with-an-llm-veneer.md index e092053..67b2950 100644 --- a/docs/adr/0004-deterministic-generation-with-an-llm-veneer.md +++ b/docs/adr/0004-deterministic-generation-with-an-llm-veneer.md @@ -33,7 +33,7 @@ Three constraints bear on who generates the structure: ## Decision The structure is assembled by a seeded deterministic generator in -`mansao.generation.generator`. The solver, in `mansao.generation.solver`, takes +`firenze.generation.generator`. The solver, in `firenze.generation.solver`, takes `Case` (never `CaseWithSolution`) and holds a veto: a case that is not deducible is discarded and regenerated from a derived seed. diff --git a/docs/adr/0005-locale-is-a-property-of-the-match.md b/docs/adr/0005-locale-is-a-property-of-the-match.md index 04bccc8..e7c6e26 100644 --- a/docs/adr/0005-locale-is-a-property-of-the-match.md +++ b/docs/adr/0005-locale-is-a-property-of-the-match.md @@ -34,9 +34,9 @@ German would need three different articles. A `Fact` carries a kind and slots — character, room id, interval index. Rooms are stable identifiers (`cellar`), never display names. Time is minutes on a clock the case defines, not a formatted string. Secrets, means and motives are -message keys. Nothing in `mansao.domain` contains a sentence. +message keys. Nothing in `firenze.domain` contains a sentence. -Catalogs live in `mansao/i18n/messages/.json` and ship with `pt-BR` and +Catalogs live in `firenze/i18n/messages/.json` and ship with `pt-BR` and `en` from day one — a second locale is the only way to know the first one is actually separable. **Grammar lives in the catalog**: each locale declares the preposition its rooms need, and its own clock format. diff --git a/infra/compose/docker-compose.yml b/infra/compose/docker-compose.yml index 7237c8b..5124090 100644 --- a/infra/compose/docker-compose.yml +++ b/infra/compose/docker-compose.yml @@ -1,13 +1,13 @@ -name: mansao +name: firenze services: db: # pgvector já compilado sobre o Postgres 16 — mesma versão do DBaaS (ADR-0002) image: pgvector/pgvector:pg16 environment: - POSTGRES_USER: ${POSTGRES_USER:-mansao} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-mansao} - POSTGRES_DB: ${POSTGRES_DB:-mansao} + POSTGRES_USER: ${POSTGRES_USER:-firenze} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-firenze} + POSTGRES_DB: ${POSTGRES_DB:-firenze} ports: # 5433 no host: 5432 costuma estar ocupada por um Postgres instalado na máquina - "5433:5432" @@ -15,7 +15,7 @@ services: - pgdata:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d:ro healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mansao} -d ${POSTGRES_DB:-mansao}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-firenze} -d ${POSTGRES_DB:-firenze}"] interval: 5s timeout: 3s retries: 10 @@ -34,9 +34,9 @@ services: build: context: ../../apps/api environment: - MANSAO_ENVIRONMENT: dev - MANSAO_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-mansao}:${POSTGRES_PASSWORD:-mansao}@db:5432/${POSTGRES_DB:-mansao} - MANSAO_REDIS_URL: redis://redis:6379/0 + FIRENZE_ENVIRONMENT: dev + FIRENZE_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-firenze}:${POSTGRES_PASSWORD:-firenze}@db:5432/${POSTGRES_DB:-firenze} + FIRENZE_REDIS_URL: redis://redis:6379/0 ports: - "8000:8000" volumes: diff --git a/infra/compose/init/01-extensions.sql b/infra/compose/init/01-extensions.sql index 22b88a8..b291209 100644 --- a/infra/compose/init/01-extensions.sql +++ b/infra/compose/init/01-extensions.sql @@ -1,4 +1,4 @@ --- Roda uma vez, na criação do volume. Para reexecutar: make down && docker volume rm mansao_pgdata +-- Roda uma vez, na criação do volume. Para reexecutar: make down && docker volume rm firenze_pgdata -- -- pgaudit fica de fora aqui de propósito: exige shared_preload_libraries e não -- vem na imagem pgvector. É extensão do DBaaS da Magalu (ADR-0002), habilitada