f-id / src /id /cli.py
marcodsn's picture
Initial Gradio Space
0423b99
Raw
History Blame Contribute Delete
13.8 kB
"""Typer CLI entrypoint and command routing (Section 11).
Commands:
id new --seed "<prompt>" [--n 3] [--profile cheap|quality]
id worlds
id play <world_id>
id resume <session_id>
id costs <world_id|session_id>
In-session verbs (explicit, lower-risk; free-text intent left for later):
talk <name> <message> look [@<location>] <query>
confront <name> <A> <B> notes accuse who help quit
"""
from __future__ import annotations
import shlex
from pathlib import Path
import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from .config import Config, load_config, load_prices
from .engine.loop import Session
from .generator.pipeline import generate_world
from .llm.client import LLMClient
from .llm.prompts import PromptRegistry
from .llm.usage import aggregate, estimate_cost
app = typer.Typer(add_completion=False, help="ID β€” an LLM-driven investigation game.")
console = Console()
def _ctx(profile: str | None = None) -> tuple[Config, PromptRegistry, LLMClient]:
cfg = load_config().with_profile(profile)
prompts = PromptRegistry(cfg.prompts_dir)
client = LLMClient(cfg)
return cfg, prompts, client
# --------------------------------------------------------------------------
# id new
# --------------------------------------------------------------------------
@app.command()
def new(
seed: str = typer.Option(..., "--seed", help="Premise prompt for the case."),
n: int = typer.Option(None, "--n", help="Candidate concepts (best-of-n)."),
profile: str | None = typer.Option(None, "--profile", help="cheap|quality"),
) -> None:
"""Generate + validate a world, add it to the archive."""
cfg, prompts, client = _ctx(profile)
n_eff = n or cfg.engine.best_of_n
# generation logs usage against the world; bind a temp ledger at world dir
result = generate_world(
config=cfg, client=client, prompts=prompts, seed=seed, n=n_eff,
on_event=lambda m: console.print(f"[dim]{m}[/dim]"),
)
if result.shipped:
# attach the world-scoped usage ledger retroactively is handled inside
console.print(Panel.fit(
f"[green]Shipped world[/green] [bold]{result.world_id}[/bold]\n"
f"attempts: {result.attempts}\n"
f"culprit (solver): {result.report.named_culprit}",
title="id new",
))
console.print(f"Play it with: [bold]id play {result.world_id}[/bold]")
else:
console.print(Panel.fit(
f"[red]Generation failed the solvability/fairness gate "
f"after {result.attempts} attempt(s).[/red]\n"
f"last solver: solved={result.report.solved} "
f"unique={result.report.unique} fair={result.report.fair}\n"
f"{result.report.fairness_detail}\n{result.report.notes}",
title="id new",
))
raise typer.Exit(1)
# --------------------------------------------------------------------------
# id worlds
# --------------------------------------------------------------------------
@app.command()
def worlds() -> None:
"""List archived worlds."""
cfg, _, _ = _ctx()
from .generator.archive import Archive
entries = Archive(cfg.worlds_dir).entries()
if not entries:
console.print("[dim]No worlds yet. Generate one with `id new --seed ...`.[/dim]")
return
table = Table(title="Archived worlds")
table.add_column("world_id", style="bold cyan")
table.add_column("one_line")
table.add_column("twist", style="magenta")
table.add_column("plays", justify="right")
for e in entries:
table.add_row(
e.get("world_id", ""), e.get("one_line", ""),
e.get("twist_tag", ""), str(e.get("play_count", 0)),
)
console.print(table)
# --------------------------------------------------------------------------
# id play / resume
# --------------------------------------------------------------------------
@app.command()
def play(world_id: str, profile: str | None = typer.Option(None, "--profile")) -> None:
"""Start a session for a world."""
cfg, prompts, client = _ctx(profile)
if not (cfg.worlds_dir / world_id).exists():
console.print(f"[red]No such world: {world_id}[/red]")
raise typer.Exit(1)
session = Session.start(cfg, world_id, prompts, client)
from .generator.archive import Archive
Archive(cfg.worlds_dir).bump_play_count(world_id)
_intro(session)
_interactive(session)
@app.command()
def resume(session_id: str, profile: str | None = typer.Option(None, "--profile")) -> None:
"""Resume an existing session (reloads ledgers, delta, transcript)."""
cfg, prompts, client = _ctx(profile)
sdir = cfg.runtime_dir / session_id
if not (sdir / "session.json").exists():
console.print(f"[red]No such session: {session_id}[/red]")
raise typer.Exit(1)
session = Session.resume(cfg, session_id, prompts, client)
console.print(f"[green]Resumed[/green] {session_id} (turn {session.state.turn}).")
_interactive(session)
# --------------------------------------------------------------------------
# id costs
# --------------------------------------------------------------------------
@app.command()
def costs(target: str) -> None:
"""Token + (optional) cost report for a world_id or session_id."""
cfg, _, _ = _ctx()
paths: list[Path] = []
sdir = cfg.runtime_dir / target
if (sdir / "usage.jsonl").exists():
paths.append(sdir / "usage.jsonl")
else:
# treat as world_id: aggregate the world's gen ledger + all its sessions
wdir = cfg.worlds_dir / target
if (wdir / "usage.jsonl").exists():
paths.append(wdir / "usage.jsonl")
if cfg.runtime_dir.exists():
for s in cfg.runtime_dir.iterdir():
if s.name.startswith(target) and (s / "usage.jsonl").exists():
paths.append(s / "usage.jsonl")
if not paths:
console.print(f"[yellow]No usage records found for {target!r}.[/yellow]")
raise typer.Exit(1)
report = aggregate(paths)
prices = load_prices(cfg.prices_path)
est = estimate_cost(report, prices) if prices else {}
t1 = Table(title="Tokens by task")
t1.add_column("task")
t1.add_column("calls", justify="right")
t1.add_column("prompt", justify="right")
t1.add_column("completion", justify="right")
t1.add_column("total", justify="right")
for task, tot in sorted(report.by_task.items(), key=lambda kv: -kv[1].total):
t1.add_row(task, str(tot.calls), str(tot.prompt), str(tot.completion), str(tot.total))
console.print(t1)
t2 = Table(title="Tokens by model")
t2.add_column("model")
t2.add_column("total", justify="right")
if est:
t2.add_column("est. USD", justify="right")
for model, tot in sorted(report.by_model.items(), key=lambda kv: -kv[1].total):
if est:
t2.add_row(model, str(tot.total), f"${est.get(model, 0.0):.4f}")
else:
t2.add_row(model, str(tot.total))
console.print(t2)
g = report.grand
line = f"[bold]Grand total[/bold]: {g.total} tokens over {g.calls} calls"
if est:
line += f" (~${sum(est.values()):.4f})"
console.print(line)
# --------------------------------------------------------------------------
# interactive session loop
# --------------------------------------------------------------------------
def _intro(session: Session) -> None:
w = session.world
console.print(Panel(w.world_md.strip()[:1200] or w.meta.one_line,
title=f"[bold]{w.meta.title or w.meta.world_id}[/bold]"))
names = ", ".join(
f"{c.name} ({c.role})" for c in w.characters.values() if c.role != "victim"
)
console.print(f"[dim]People you can question:[/dim] {names}")
console.print(f"[dim]Session:[/dim] {session.state.session_id}")
console.print("[dim]Type `help` for verbs, `quit` to leave (progress is saved).[/dim]")
HELP = """[bold]Verbs[/bold]
talk <name> <message> Question a character.
look [@<location>] <query> Ask the world (e.g. look @study under the desk).
confront <name> <A> <B> Pin two of their statement ids (see `notes`).
notes Your case file: statements + clues found.
who List people and locations.
accuse Name culprit + means + motive + opportunity.
help This.
quit Save and exit.
"""
def _interactive(session: Session) -> None:
while True:
try:
raw = console.input("\n[bold green]> [/bold green]").strip()
except (EOFError, KeyboardInterrupt):
console.print("\n[dim]Saved. Goodbye.[/dim]")
return
if not raw:
continue
try:
parts = shlex.split(raw)
except ValueError:
parts = raw.split()
verb = parts[0].lower()
if verb in ("quit", "exit"):
console.print("[dim]Saved. Goodbye.[/dim]")
return
if verb == "help":
console.print(HELP)
continue
if verb == "who":
_who(session)
continue
if verb == "notes":
_notes(session)
continue
if verb == "talk":
_talk(session, parts[1:])
continue
if verb == "look":
_look(session, parts[1:], raw)
continue
if verb == "confront":
_confront(session, parts[1:])
continue
if verb == "accuse":
_accuse(session)
if session.state.status.value != "active":
return
continue
console.print(f"[yellow]Unknown verb: {verb}. Try `help`.[/yellow]")
def _who(session: Session) -> None:
locs = sorted({s.location for s in session.world.timeline.slices})
for c in session.world.characters.values():
tag = "victim" if c.role == "victim" else c.role
console.print(f" [cyan]{c.name}[/cyan] β€” {tag}")
if locs:
console.print(f"[dim]Locations:[/dim] {', '.join(locs)}")
def _talk(session: Session, args: list[str]) -> None:
if len(args) < 2:
console.print("[yellow]Usage: talk <name> <message>[/yellow]")
return
name, message = args[0], " ".join(args[1:])
try:
outcome = session.talk(name, message)
except KeyError as e:
console.print(f"[red]{e}[/red]")
return
title = f"{name}" + (" β€” [red]cracking[/red]" if outcome.cracked else "")
border = "red" if outcome.cracked else "blue"
console.print(Panel(outcome.text, title=title, border_style=border))
for cid in outcome.discovered_clues:
console.print(f"[bold yellow]β˜… Clue discovered:[/bold yellow] {cid}")
def _look(session: Session, args: list[str], raw: str) -> None:
location = None
if args and args[0].startswith("@"):
location = args[0][1:]
query = " ".join(args[1:])
else:
query = " ".join(args)
if not query:
console.print("[yellow]Usage: look [@location] <query>[/yellow]")
return
answer = session.look(query, location)
console.print(Panel(answer.text, title="The scene", border_style="green"))
if answer.discovered_clue:
console.print(f"[bold yellow]β˜… Clue discovered:[/bold yellow] {answer.discovered_clue}")
def _confront(session: Session, args: list[str]) -> None:
if len(args) != 3:
console.print("[yellow]Usage: confront <name> <statementA_id> <statementB_id>[/yellow]")
console.print("[dim]Statement ids come from `notes`.[/dim]")
return
name, a, b = args
try:
result = session.confront(name, a, b)
except KeyError as e:
console.print(f"[red]{e}[/red]")
return
style = "bold red" if result.verified else "yellow"
label = "VERIFIED CONTRADICTION" if result.verified else "no contradiction"
console.print(Panel(result.reason, title=f"Confront {name}: {label}", border_style=style))
if result.verified:
console.print("[dim]Press them now β€” this is leverage.[/dim]")
def _notes(session: Session) -> None:
notes = session.notes()
if notes.discovered_clues:
console.print("[bold]Clues discovered[/bold]")
for c in notes.discovered_clues:
console.print(f" β˜… {c['id']}: {c['reveals']}")
else:
console.print("[dim]No clues discovered yet.[/dim]")
if notes.cracked:
console.print(f"[bold red]Cracked:[/bold red] {', '.join(notes.cracked)}")
if notes.ledgers:
console.print("\n[bold]Statements on record[/bold] [dim](id β€” proposition)[/dim]")
for name, claims in notes.ledgers.items():
console.print(f"[cyan]{name}[/cyan]:")
for cl in claims:
console.print(f" [dim]{cl['claim_id']}[/dim] β€” {cl['proposition']}")
else:
console.print("[dim]No one has committed to anything yet.[/dim]")
def _accuse(session: Session) -> None:
console.print("[bold red]ACCUSATION[/bold red] β€” this ends the case.")
culprit = console.input("Culprit: ").strip()
if not culprit:
console.print("[dim]Cancelled.[/dim]")
return
means = console.input("Means: ").strip()
motive = console.input("Motive: ").strip()
opportunity = console.input("Opportunity: ").strip()
result = session.accuse(culprit, means, motive, opportunity)
border = {"solved": "green", "right_culprit_unproven": "yellow", "wrong": "red"}[result.grade]
console.print(Panel(result.debrief, title="Debrief", border_style=border))
if __name__ == "__main__":
app()