feat: implement core D&D helpers logic and system architecture

This commit is contained in:
2026-05-25 22:14:58 -07:00
parent 5bb483431f
commit 685586318f
36 changed files with 1137 additions and 0 deletions
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
import asyncio
from typing import List
import typer
from src.llm.models import CharacterStateUpdate, ExtractionResult, LoreUpdate
from src.pipeline.orchestrator import PipelineOrchestrator
from src.ui.tui import ConfirmationApp
app = typer.Typer(help="D&D Helpers CLI")
@app.command()
def run():
"""
Start the main D&D Helpers pipeline.
"""
typer.echo("Starting D&D Helpers pipeline...")
loop = asyncio.get_event_loop()
orchestrator = PipelineOrchestrator(loop=loop)
try:
loop.run_until_complete(orchestrator.run())
except KeyboardInterrupt:
orchestrator.stop()
loop.run_until_complete(asyncio.sleep(0)) # Give it a moment to cleanup
typer.echo("Pipeline stopped.")
@app.command()
def confirm(
lore_text: List[str] = typer.Argument(..., help="Sample lore updates"),
char_name: str = typer.Option("Grog", help="Character name for sample update"),
hp_change: int = typer.Option(0, help="Sample HP change"),
):
"""
Test the confirmation TUI with sample data.
"""
# Mocking an ExtractionResult
lore_updates = [
LoreUpdate(category="NPC", entity_name="Torm", content=text)
for text in lore_text
]
char_updates = [CharacterStateUpdate(character_name=char_name, hp_change=hp_change)]
result = ExtractionResult(lore_updates=lore_updates, character_updates=char_updates)
# Launch the TUI
ConfirmationApp(result).run()
if __name__ == "__main__":
app()
+241
View File
@@ -0,0 +1,241 @@
from typing import List, Union
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Button, DataTable, Footer, Input, Label, Static
from src.llm.models import CharacterStateUpdate, ExtractionResult, LoreUpdate
from src.persistence.characters import update_character_state
from src.persistence.lore import update_lore
class ConfirmationApp(App):
CSS = """
Screen {
layout: horizontal;
}
#left-pane {
width: 40%;
border: solid 1;
padding: 1;
}
#right-pane {
width: 60%;
border: solid 1;
padding: 1;
layout: vertical;
}
#details-container {
height: auto;
margin-bottom: 1;
}
#actions-container {
height: auto;
layout: horizontal;
align: center middle;
}
#edit-container {
display: none;
height: auto;
layout: vertical;
border: solid 1;
padding: 1;
}
Button {
margin: 0 1;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
]
def __init__(self, result: ExtractionResult):
super().__init__()
self.result = result
self.pending_updates: List[Union[LoreUpdate, CharacterStateUpdate]] = []
# Populate pending updates from result
self.pending_updates.extend(result.lore_updates)
self.pending_updates.extend(result.character_updates)
self.selected_index = -1
def compose(self) -> ComposeResult:
yield Container(
Horizontal(
Vertical(
DataTable(id="update-table"),
id="left-pane",
),
Vertical(
Vertical(
Label("Details:", id="details-label"),
Static("No update selected", id="details-text"),
id="details-container",
),
Vertical(
Label("Edit Value:"),
Input(id="edit-input"),
Button("Save Edit", id="save-edit"),
id="edit-container",
),
Horizontal(
Button("Accept", id="btn-accept"),
Button("Reject", id="btn-reject"),
Button("Edit", id="btn-edit"),
id="actions-container",
),
id="right-pane",
),
),
Footer(),
)
def on_mount(self) -> None:
table = self.query_one("#update-table", DataTable)
table.add_columns("Type", "Target", "Update")
for i, update in enumerate(self.pending_updates):
if isinstance(update, LoreUpdate):
table.add_row(
"Lore", update.entity_name or "General", update.content, key=str(i)
)
elif isinstance(update, CharacterStateUpdate):
change_text = f"HP: {update.hp_change or 0}"
if update.status_effects_added:
change_text += f", Added: {', '.join(update.status_effects_added)}"
if update.status_effects_removed:
change_text += (
f", Removed: {', '.join(update.status_effects_removed)}"
)
table.add_row("Char", update.character_name, change_text, key=str(i))
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
self.selected_index = event.row
update = self.pending_updates[self.selected_index]
details_text = self.query_one("#details-text", Static)
if isinstance(update, LoreUpdate):
details_text.update(
f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}"
)
elif isinstance(update, CharacterStateUpdate):
details_text.update(
f"Character: {update.character_name}\nHP Change: {update.hp_change}\nAdded Effects: {update.status_effects_added}\nRemoved Effects: {update.status_effects_removed}"
)
# Reset to detail view
self.query_one("#edit-container", Vertical).styles.display = "none"
self.query_one("#details-container", Vertical).styles.display = "block"
def on_button_clicked(self, event: Button.Clicked) -> None:
if self.selected_index == -1:
return
update = self.pending_updates[self.selected_index]
if event.button.id == "btn-accept":
if isinstance(update, LoreUpdate):
update_lore(update)
elif isinstance(update, CharacterStateUpdate):
update_character_state(update)
self.remove_update(self.selected_index)
elif event.button.id == "btn-reject":
self.remove_update(self.selected_index)
elif event.button.id == "btn-edit":
self.show_edit_mode(update)
elif event.button.id == "save-edit":
self.save_edit(update)
def show_edit_mode(self, update: Union[LoreUpdate, CharacterStateUpdate]) -> None:
edit_input = self.query_one("#edit-input", Input)
if isinstance(update, LoreUpdate):
edit_input.value = update.content
elif isinstance(update, CharacterStateUpdate):
# For simplicity, only allow editing HP change in this TUI
edit_input.value = str(update.hp_change or 0)
self.query_one("#edit-container", Vertical).styles.display = "block"
self.query_one("#details-container", Vertical).styles.display = "none"
def save_edit(self, update: Union[LoreUpdate, CharacterStateUpdate]) -> None:
new_val = self.query_one("#edit-input", Input).value
if isinstance(update, LoreUpdate):
update.content = new_val
elif isinstance(update, CharacterStateUpdate):
try:
update.hp_change = int(new_val)
except ValueError:
# Ignore invalid integer input
pass
# Refresh the table
table = self.query_one("#update-table", DataTable)
# Textual DataTable doesn't have a simple 'update_row', so we clear and refill
# or we can use update_cell.
# Update the table row
if isinstance(update, LoreUpdate):
table.update_cell(self.selected_index, 2, update.content)
elif isinstance(update, CharacterStateUpdate):
change_text = f"HP: {update.hp_change or 0}"
if update.status_effects_added:
change_text += f", Added: {', '.join(update.status_effects_added)}"
if update.status_effects_removed:
change_text += f", Removed: {', '.join(update.status_effects_removed)}"
table.update_cell(self.selected_index, 2, change_text)
self.show_edit_mode(update) # just to refresh the value maybe? No,
# Actually let's go back to detail view
self.query_one("#edit-container", Vertical).styles.display = "none"
self.query_one("#details-container", Vertical).styles.display = "block"
# Update details text
details_text = self.query_one("#details-text", Static)
if isinstance(update, LoreUpdate):
details_text.update(
f"Category: {update.category}\nTarget: {update.entity_name}\nContent: {update.content}"
)
elif isinstance(update, CharacterStateUpdate):
details_text.update(
f"Character: {update.character_name}\nHP Change: {update.hp_change}\nAdded Effects: {update.status_effects_added}\nRemoved Effects: {update.status_effects_removed}"
)
def remove_update(self, index: int) -> None:
# Remove from the pending list
del self.pending_updates[index]
# Clear and refill the table
table = self.query_one("#update-table", DataTable)
table.clear()
for i, update in enumerate(self.pending_updates):
if isinstance(update, LoreUpdate):
table.add_row(
"Lore", update.entity_name or "General", update.content, key=str(i)
)
elif isinstance(update, CharacterStateUpdate):
change_text = f"HP: {update.hp_change or 0}"
if update.status_effects_added:
change_text += f", Added: {', '.join(update.status_effects_added)}"
if update.status_effects_removed:
change_text += (
f", Removed: {', '.join(update.status_effects_removed)}"
)
table.add_row("Char", update.character_name, change_text, key=str(i))
self.selected_index = -1
self.query_one("#details-text", Static).update("No update selected")