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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ COMPOSE := docker compose -f infra/compose/docker-compose.yml
API := apps/api

.DEFAULT_GOAL := help
.PHONY: help dev down logs psql install api case openapi lint fmt typecheck test check migrate evals
.PHONY: help dev down logs psql install api case ask openapi lint fmt typecheck test check migrate evals

help: ## lista os alvos
@grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) | sed 's/:.*## /\t/' | expand -t 14
Expand All @@ -29,6 +29,9 @@ api: ## roda a API local com reload (sem container)
case: ## gera um caso (make case SEED=42 [LOCALE=en] [REVEAL=1] [VENEER=1])
cd $(API) && uv run firenze generate --seed $(or $(SEED),1) --locale $(or $(LOCALE),pt-BR) $(if $(REVEAL),--reveal,) $(if $(VENEER),--veneer,)

ask: ## pergunta a um suspeito (make ask SEED=42 WHO=sus-1 Q="onde voce estava?")
cd $(API) && uv run firenze ask --seed $(or $(SEED),1) --suspect $(or $(WHO),sus-1) --question "$(Q)" --locale $(or $(LOCALE),pt-BR)

openapi: ## regenera apps/api/openapi.json
cd $(API) && uv run python scripts/dump_openapi.py

Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ character has no way of knowing.

![CI](https://github.com/Madeuss/firenze/actions/workflows/ci.yml/badge.svg)

> **Status: phase 1 of 8.** The case generator and its solver work. There are no
> NPCs yet — the next phase gives one suspect a voice. Not playable.
> **Status: phase 2 of 8.** Cases generate and a suspect answers questions,
> guarded end to end. The words are still synthetic — the model provider
> (Magalu Prosa) is in pilot. Not playable yet.

## See it work

Expand Down Expand Up @@ -126,6 +127,7 @@ the database.
```bash
make install # sync the environment from uv.lock
make case SEED=42 REVEAL=1 # generate a case and see the solver's chain
FIRENZE_MODEL_PROVIDER=fake \n make ask SEED=42 WHO=sus-1 Q="onde você estava às 22h?" # question a suspect, offline
make check # lint, typecheck, tests — what CI enforces

make dev # Postgres 16 + pgvector, Redis, the API
Expand All @@ -140,6 +142,8 @@ curl localhost:8000/health
|---|---|
| [`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/interrogation/`](apps/api/src/firenze/interrogation/) | The turn: dossier, prompt, guards, stance machine |
| [`apps/api/src/firenze/model/`](apps/api/src/firenze/model/) | The model port. No other module names a provider |
| [`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 |
Expand All @@ -166,7 +170,7 @@ transcribed — duplicated text drifts.
| Phase | Scope | Status |
|---|---|---|
| 0 | Foundation — repo, docs, local stack | done |
| 1 | Case generator and deducibility solver | in progress |
| 1 | Case generator and deducibility solver | done |
| 2 | A single NPC: isolated dossier, structured output, streaming | |
| 3 | Security: canary, input classifier, output filter, CI gates | |
| 4 | Full game: six NPCs, evidence, confrontation, verdict | |
Expand Down
50 changes: 49 additions & 1 deletion apps/api/src/firenze/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
from collections.abc import Sequence

from firenze.config import settings
from firenze.domain import CaseWithSolution, FactKind, Role
from firenze.domain import CaseWithSolution, FactKind, Match, Role
from firenze.generation import UnsolvableCase, generate, solve
from firenze.i18n import DEFAULT_LOCALE, Catalog, UnknownLocale, available_locales, load
from firenze.interrogation import ask
from firenze.model import ModelUnavailable, resolve
from firenze.veneer import CaseVeneer, VeneerRejected, VeneerUnavailable, write

Expand Down Expand Up @@ -96,12 +97,21 @@ def main(argv: Sequence[str] | None = None) -> int:
)
gen.add_argument("--json", action="store_true", help="print the case as JSON, no solution")

interrogate = sub.add_parser("ask", help="put a question to one suspect")
interrogate.add_argument("--seed", type=int, required=True)
interrogate.add_argument("--suspect", required=True, help="e.g. sus-1")
interrogate.add_argument("--question", required=True)
interrogate.add_argument("--locale", default=DEFAULT_LOCALE)

args = parser.parse_args(argv)

# The Windows console opens in cp1252 and eats the accents in the briefing.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")

if args.command == "ask":
return _ask(args)

try:
catalog = load(args.locale)
full = generate(seed=args.seed, suspects=args.suspects)
Expand Down Expand Up @@ -130,5 +140,43 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0


def _ask(args: argparse.Namespace) -> int:
"""One question, one guarded answer.

Runs end to end with FIRENZE_MODEL_PROVIDER=fake: the dossier, the prompt,
the schema, the scope and canary checks and the stance machine are all
exercised. Only the words are synthetic.
"""
try:
catalog = load(args.locale)
model = resolve(
settings.model_provider,
model=settings.model_name,
base_url=settings.model_base_url,
api_key=settings.model_api_key.get_secret_value(),
)
match = Match(full_case=generate(seed=args.seed), locale=args.locale)
result = ask(match, args.suspect, args.question, catalog=catalog, model=model)
except (UnsolvableCase, UnknownLocale, ModelUnavailable, KeyError, ValueError) as failure:
print(f"error: {failure}", file=sys.stderr)
return 1

name = match.case.name_of(args.suspect)
if result.statement is None:
print(f"{name} não respondeu. Motivo: {result.rejection}", file=sys.stderr)
print(f"Turnos restantes: {result.match.turns_left}")
return 1

print(f"{name} ({result.statement.stance.value})")
print(f" {result.statement.line}")
print()
print(f" mentiu: {result.statement.lied}")
print(f" fato citado: {result.statement.fact_referenced or '—'}")
if result.stance_overruled:
print(" postura sugerida foi recusada pela máquina de estados")
print(f" turnos restantes: {result.match.turns_left}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
6 changes: 6 additions & 0 deletions apps/api/src/firenze/domain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
Character,
Fact,
FactKind,
Match,
Role,
Scope,
Solution,
Stance,
Statement,
)

__all__ = [
Expand All @@ -17,7 +20,10 @@
"Character",
"Fact",
"FactKind",
"Match",
"Role",
"Scope",
"Solution",
"Stance",
"Statement",
]
59 changes: 59 additions & 0 deletions apps/api/src/firenze/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ class FactKind(StrEnum):
"""A suspect's private secret. A reason to lie without being the culprit."""


class Stance(StrEnum):
"""How a suspect is holding up. Transitions are code, never the model's call.

The model may suggest one; the backend validates it against the machine in
`firenze.interrogation.stance` and keeps the current stance if the
suggestion is not a legal move (RN-023). `broken` is absorbing and is only
reachable by confrontation, which arrives in phase 4.
"""

cooperative = "cooperative"
evasive = "evasive"
hostile = "hostile"
broken = "broken"


class Character(BaseModel):
model_config = ConfigDict(frozen=True)

Expand Down Expand Up @@ -149,3 +164,47 @@ class CaseWithSolution(BaseModel):

case: Case
solution: Solution


class Statement(BaseModel):
"""Something a suspect said, kept because contradiction is found in the record.

Persisted per turn and per character rather than per match: RN-021 compares a
suspect against themselves, and nobody contradicts anybody else.
"""

model_config = ConfigDict(frozen=True)

turn: int
character: str
question: str
line: str
stance: Stance
lied: bool
fact_referenced: str | None = None


class Match(BaseModel):
"""One playthrough. Holds the solution, because the server has to know it.

The boundary is not here — it is the dossier. `Match` is what the verdict
will be computed from (RN-032); `Dossier` is what a model is allowed to see.
"""

model_config = ConfigDict(frozen=True)

full_case: CaseWithSolution
locale: str
turns_left: int = 30
stances: dict[str, Stance] = Field(default_factory=dict)
statements: tuple[Statement, ...] = ()

@property
def case(self) -> Case:
return self.full_case.case

def stance_of(self, character: str) -> Stance:
return self.stances.get(character, Stance.cooperative)

def said_by(self, character: str) -> tuple[Statement, ...]:
return tuple(s for s in self.statements if s.character == character)
16 changes: 16 additions & 0 deletions apps/api/src/firenze/interrogation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""One suspect, one question, one guarded answer."""

from firenze.interrogation.dossier import Dossier, build
from firenze.interrogation.guard import ReplyRejected
from firenze.interrogation.models import NpcReply
from firenze.interrogation.turn import PROMPT_VERSION, TurnResult, ask

__all__ = [
"PROMPT_VERSION",
"Dossier",
"NpcReply",
"ReplyRejected",
"TurnResult",
"ask",
"build",
]
60 changes: 60 additions & 0 deletions apps/api/src/firenze/interrogation/dossier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""The boundary between what the server knows and what a model may see.

`Match` holds the solution — it has to, because the verdict is computed from it
(RN-032). This module is the one place allowed to read it, and what comes out
the other side is a `Dossier`: one suspect's facts, plus one bit about
themselves.

That bit exists because of a gap the case model does not otherwise fill. The
culprit was alone with the victim, so no presence fact was ever written about
them at that hour, and nothing in their own dossier incriminates them. Without
being told, a culprit is indistinguishable from an innocent who happens to lack
an alibi — they would answer with the same easy conscience, and the mystery
would have no centre.

RN-011 anticipates exactly this: the `Solution` entity never enters a suspect's
context, but *"o culpado sabe apenas da própria culpa"*. So `is_culprit` crosses
the boundary and nothing else does. A suspect learns whether they did it, never
who else might have, never the means, never the motive, never the chain.
"""

from pydantic import BaseModel, ConfigDict

from firenze.domain import Fact, Match, Stance, Statement


class Dossier(BaseModel):
"""Everything one suspect knows, and nothing else.

This is the type the prompt builder takes. It cannot reach another suspect's
secret, the solution, or the deduction chain, because it does not carry
them (RN-010, RN-011).
"""

model_config = ConfigDict(frozen=True)

character: str
name: str
is_culprit: bool
"""Only ever true for the one suspect it is true of."""
stance: Stance
facts: tuple[Fact, ...]
said_before: tuple[Statement, ...]
"""Their own past statements. RN-021 compares a suspect against themselves."""


def build(match: Match, character: str) -> Dossier:
"""Project the match down to one suspect's view of the night."""
case = match.case
known = {c.id for c in case.suspects}
if character not in known:
raise KeyError(f"{character!r} is not a suspect in this case")

return Dossier(
character=character,
name=case.name_of(character),
is_culprit=match.full_case.solution.culprit == character,
stance=match.stance_of(character),
facts=case.dossier(character),
said_before=match.said_by(character),
)
72 changes: 72 additions & 0 deletions apps/api/src/firenze/interrogation/guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""What a reply has to survive before a player sees it. (RN-042)

Three checks, in order of severity.

**Canary.** Every restricted fact carries a token (RN-012). One in the output
means a secret reached the model as text and came back out. The reply is
discarded and the event is recorded; there is no version of this that gets
repaired and shown.

**Scope.** A reply may cite a fact — that is what `fact_referenced` is for — but
only one this suspect actually holds. Citing a fact from someone else's dossier
means the context was assembled wrong, which is a bug on our side rather than a
lie on theirs (RN-010).

**Shape.** An empty line is not an answer, and a runaway one is a model that
stopped playing a character and started narrating.

The stance is not checked here. An illegal stance is not a violation to reject —
it is a suggestion to overrule, and `stance.settle` does that.
"""

import re

from firenze.interrogation.dossier import Dossier
from firenze.interrogation.models import NpcReply

CANARY = re.compile(r"CN-[0-9a-f]{8}")
MAX_LINE_CHARS = 1200


class ReplyRejected(ValueError):
"""A reply that leaked, cited what it could not know, or was not an answer."""

def __init__(self, check: str, detail: str) -> None:
super().__init__(f"{check}: {detail}")
self.check = check
self.detail = detail


def check(reply: NpcReply, dossier: Dossier) -> None:
"""Raise `ReplyRejected` on the first violation."""
no_canary(reply)
only_known_facts(reply, dossier)
is_an_answer(reply)


def no_canary(reply: NpcReply) -> None:
"""RN-012. Critical: the response is discarded, never edited."""
leaked = CANARY.findall(reply.line)
if leaked:
raise ReplyRejected("canary", f"secret token in the reply: {sorted(set(leaked))}")


def only_known_facts(reply: NpcReply, dossier: Dossier) -> None:
"""A suspect may only cite what is in their own dossier. (RN-010)"""
known = {fact.id for fact in dossier.facts}
for field, cited in (
("fact_referenced", reply.fact_referenced),
("clue_revealed", reply.clue_revealed),
):
if cited and cited not in known:
raise ReplyRejected(
"scope",
f"{dossier.character} cited {cited} via {field}, which is not in their dossier",
)


def is_an_answer(reply: NpcReply) -> None:
if not reply.line.strip():
raise ReplyRejected("empty", "the reply has no line")
if len(reply.line) > MAX_LINE_CHARS:
raise ReplyRejected("length", f"the line runs {len(reply.line)} chars")
Loading
Loading