feat: implement core D&D helpers logic and system architecture
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# D&D Helpers Configuration
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
LLM_MODEL=gpt-4o
|
||||
WHISPER_MODEL=base
|
||||
AUDIO_DEVICE_ID=None
|
||||
@@ -0,0 +1 @@
|
||||
- The party defeated the goblins.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Core dependencies for D&D Helpers
|
||||
faster-whisper
|
||||
sounddevice
|
||||
pydantic
|
||||
textual
|
||||
typer
|
||||
openai
|
||||
python-dotenv
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
# LLM Module
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoreUpdate(BaseModel):
|
||||
category: str = Field(
|
||||
...,
|
||||
description="Category of lore: 'NPC', 'Location', 'WorldBuilding', or 'Plot'",
|
||||
)
|
||||
entity_name: Optional[str] = Field(
|
||||
None, description="The name of the NPC, Location, or entity being updated"
|
||||
)
|
||||
content: str = Field(..., description="The actual lore fact or update")
|
||||
context: Optional[str] = Field(
|
||||
None, description="Brief context from the conversation that led to this update"
|
||||
)
|
||||
|
||||
|
||||
class InventoryChange(BaseModel):
|
||||
item: str = Field(..., description="Name of the item")
|
||||
quantity: int = Field(1, description="Quantity of the item")
|
||||
action: str = Field(..., description="Either 'added' or 'removed'")
|
||||
|
||||
|
||||
class CharacterStateUpdate(BaseModel):
|
||||
character_name: str = Field(
|
||||
..., description="Name of the character whose state is changing"
|
||||
)
|
||||
hp_change: Optional[int] = Field(
|
||||
None, description="Change in HP (negative for damage, positive for healing)"
|
||||
)
|
||||
status_effects_added: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of status effects applied to the character",
|
||||
)
|
||||
status_effects_removed: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of status effects removed from the character",
|
||||
)
|
||||
inventory_changes: List[InventoryChange] = Field(
|
||||
default_factory=list,
|
||||
description="List of items added or removed from inventory",
|
||||
)
|
||||
|
||||
|
||||
class ExtractionResult(BaseModel):
|
||||
lore_updates: List[LoreUpdate] = Field(
|
||||
default_factory=list, description="List of discovered lore facts"
|
||||
)
|
||||
character_updates: List[CharacterStateUpdate] = Field(
|
||||
default_factory=list, description="List of character state changes"
|
||||
)
|
||||
significant_events: List[str] = Field(
|
||||
default_factory=list, description="List of significant plot points or events"
|
||||
)
|
||||
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openai import OpenAI
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .models import ExtractionResult
|
||||
from .prompts import EXTRACTION_SYSTEM_PROMPT, NOISE_FILTER_SYSTEM_PROMPT
|
||||
|
||||
|
||||
class LLMProcessor:
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
model: str = "gpt-4o",
|
||||
):
|
||||
"""
|
||||
Initializes the LLMProcessor.
|
||||
|
||||
:param api_key: OpenAI API key. If None, it looks for OPENAI_API_KEY in environment variables.
|
||||
:param base_url: OpenAI-compatible base URL (e.g., for vLLM).
|
||||
:param model: The model to use for processing.
|
||||
"""
|
||||
self.client = OpenAI(
|
||||
api_key=api_key or os.environ.get("OPENAI_API_KEY"),
|
||||
base_url=base_url or os.environ.get("OPENAI_BASE_URL"),
|
||||
)
|
||||
self.model = model
|
||||
|
||||
def _call_llm(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
response_format: Optional[Any] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generic method to call the LLM.
|
||||
"""
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
response_format=response_format,
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
print(f"LLM Error: {e}")
|
||||
return ""
|
||||
|
||||
def filter_transcript(self, text: str) -> str:
|
||||
"""
|
||||
Stage 1: Raw Transcript -> Filtered Text.
|
||||
"""
|
||||
return self._call_llm(NOISE_FILTER_SYSTEM_PROMPT, text)
|
||||
|
||||
def extract_structured_data(self, filtered_text: str) -> ExtractionResult:
|
||||
"""
|
||||
Stage 2: Filtered Text -> Structured Data.
|
||||
"""
|
||||
# We use OpenAI's structured output (JSON mode/tool calling) via Pydantic's response_format.
|
||||
# For models that support it, we can pass the Pydantic model directly.
|
||||
# If we are using an older model or vLLM, we might need to manually parse the JSON.
|
||||
|
||||
# Using the newer 'beta.chat.completions.parse' for Pydantic support
|
||||
try:
|
||||
completion = self.client.beta.chat.completions.parse(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": filtered_text},
|
||||
],
|
||||
response_format=ExtractionResult,
|
||||
)
|
||||
return completion.choices[0].message.parsed
|
||||
except Exception as e:
|
||||
print(f"Extraction Error: {e}")
|
||||
# Return an empty ExtractionResult if parsing fails
|
||||
return ExtractionResult()
|
||||
|
||||
def process_pipeline(self, raw_text: str) -> ExtractionResult:
|
||||
"""
|
||||
Executes the two-stage pipeline: Raw Transcript -> Filtered Text -> Structured Data.
|
||||
"""
|
||||
filtered_text = self.filter_transcript(raw_text)
|
||||
if not filtered_text:
|
||||
return ExtractionResult()
|
||||
|
||||
return self.extract_structured_data(filtered_text)
|
||||
@@ -0,0 +1,20 @@
|
||||
# System prompts for the LLM pipeline
|
||||
|
||||
NOISE_FILTER_SYSTEM_PROMPT = """
|
||||
You are a D&D Game Master's assistant. Given a transcript, remove all out-of-character (OOC) chatter, logistical discussions (e.g., 'Where is my d20?'), and non-relevant noise.
|
||||
Output only the in-character dialogue and game-relevant events.
|
||||
Keep the original speakers' names if they are present in the transcript.
|
||||
Do not add any commentary or summaries. Just filter the text.
|
||||
"""
|
||||
|
||||
EXTRACTION_SYSTEM_PROMPT = """
|
||||
You are a D&D session analyzer. Your goal is to extract structured data from a filtered transcript.
|
||||
Extract any changes to character states (HP, status effects, inventory) and any new lore facts (NPCs, locations, world-building).
|
||||
|
||||
Guidelines:
|
||||
1. Lore: Identify any new information about the world, people, and places.
|
||||
2. Character State: Look for mentions of damage, healing, or items being gained or lost.
|
||||
3. Events: Note significant plot developments.
|
||||
|
||||
Be precise. If no relevant information is found, return empty lists.
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
# Persistence module for D&D Helpers.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,90 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from src.llm.models import CharacterStateUpdate
|
||||
|
||||
DATA_CHARS_DIR = Path("data/chars")
|
||||
|
||||
|
||||
def ensure_chars_dir():
|
||||
"""Ensures the character data directory exists."""
|
||||
DATA_CHARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_character_state(character_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Reads character state from a JSON file.
|
||||
If the character doesn't exist, returns a default state.
|
||||
"""
|
||||
ensure_chars_dir()
|
||||
file_path = DATA_CHARS_DIR / f"{character_name}.json"
|
||||
|
||||
if not file_path.exists():
|
||||
return {
|
||||
"character_name": character_name,
|
||||
"stats": {"hp": 0, "max_hp": 0, "ac": 0},
|
||||
"status_effects": [],
|
||||
"inventory": [],
|
||||
}
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def update_character_state(update: CharacterStateUpdate):
|
||||
"""
|
||||
Updates character state based on a CharacterStateUpdate model.
|
||||
Reads the current state, applies changes, and writes it back.
|
||||
"""
|
||||
ensure_chars_dir()
|
||||
|
||||
state = get_character_state(update.character_name)
|
||||
|
||||
# Update HP
|
||||
if update.hp_change is not None:
|
||||
current_hp = state.get("stats", {}).get("hp", 0)
|
||||
state["stats"]["hp"] = current_hp + update.hp_change
|
||||
|
||||
# Update Status Effects
|
||||
status_effects = set(state.get("status_effects", []))
|
||||
for effect in update.status_effects_added:
|
||||
status_effects.add(effect)
|
||||
for effect in update.status_effects_removed:
|
||||
status_effects.discard(effect)
|
||||
state["status_effects"] = list(status_effects)
|
||||
|
||||
# Update Inventory
|
||||
inventory = state.get("inventory", [])
|
||||
for change in update.inventory_changes:
|
||||
# Find if item already exists
|
||||
item_exists = False
|
||||
for item in inventory:
|
||||
if item["item"] == change.item:
|
||||
if change.action == "added":
|
||||
item["quantity"] = item.get("quantity", 1) + change.quantity
|
||||
elif change.action == "removed":
|
||||
item["quantity"] = max(0, item.get("quantity", 1) - change.quantity)
|
||||
item_exists = True
|
||||
break
|
||||
|
||||
if not item_exists:
|
||||
if change.action == "added":
|
||||
inventory.append(
|
||||
{
|
||||
"item": change.item,
|
||||
"quantity": change.quantity,
|
||||
"weight": 0, # Default weight if not provided
|
||||
}
|
||||
)
|
||||
elif change.action == "removed":
|
||||
# Item not in inventory, do nothing or log
|
||||
pass
|
||||
|
||||
state["inventory"] = inventory
|
||||
|
||||
file_path = DATA_CHARS_DIR / f"{update.character_name}.json"
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=4)
|
||||
|
||||
return str(file_path)
|
||||
@@ -0,0 +1,55 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from src.llm.models import LoreUpdate
|
||||
|
||||
DATA_LORE_DIR = Path("data/lore")
|
||||
|
||||
CATEGORY_MAP = {
|
||||
"NPC": "NPCs",
|
||||
"Location": "Locations",
|
||||
"WorldBuilding": "World",
|
||||
"Plot": "Timeline",
|
||||
}
|
||||
|
||||
|
||||
def ensure_lore_dir():
|
||||
"""Ensures the lore data directory and subdirectories exist."""
|
||||
DATA_LORE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for folder in CATEGORY_MAP.values():
|
||||
if folder != "Timeline":
|
||||
(DATA_LORE_DIR / folder).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def update_lore(update: LoreUpdate):
|
||||
"""
|
||||
Updates lore based on a LoreUpdate model.
|
||||
- For NPC/Location/WorldBuilding: Updates the specific entity's file.
|
||||
- For Plot: Appends to Timeline.md.
|
||||
"""
|
||||
ensure_lore_dir()
|
||||
|
||||
category = update.category
|
||||
folder = CATEGORY_MAP.get(category, "Other")
|
||||
|
||||
if category == "Plot":
|
||||
file_path = DATA_LORE_DIR / "Timeline.md"
|
||||
content_to_append = f"- {update.content}\n"
|
||||
elif update.entity_name:
|
||||
category_folder = CATEGORY_MAP.get(category, "Other")
|
||||
file_path = DATA_LORE_DIR / category_folder / f"{update.entity_name}.md"
|
||||
# For entity-specific files, we can append the content as a list item.
|
||||
# In a more complex system, we might check for existing headers.
|
||||
content_to_append = f"- {update.content}\n"
|
||||
else:
|
||||
# Fallback if no entity name is provided for a category that expects one.
|
||||
# We'll put it in a general file for that category.
|
||||
category_folder = CATEGORY_MAP.get(category, "Other")
|
||||
file_path = DATA_LORE_DIR / category_folder / "General.md"
|
||||
content_to_append = f"- {update.content}\n"
|
||||
|
||||
with open(file_path, "a", encoding="utf-8") as f:
|
||||
f.write(content_to_append)
|
||||
|
||||
return str(file_path)
|
||||
Binary file not shown.
@@ -0,0 +1,155 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from src.llm.models import ExtractionResult
|
||||
from src.llm.processor import LLMProcessor
|
||||
from src.stt.listener import AudioListener
|
||||
from src.stt.transcriber import Transcriber
|
||||
from src.ui.tui import ConfirmationApp
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PipelineOrchestrator:
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop):
|
||||
self.loop = loop
|
||||
|
||||
# Modules
|
||||
self.listener = AudioListener(loop=self.loop)
|
||||
self.transcriber = Transcriber()
|
||||
self.processor = LLMProcessor()
|
||||
|
||||
# Queues
|
||||
self.transcript_queue = asyncio.Queue()
|
||||
self.proposal_queue = asyncio.Queue()
|
||||
|
||||
self.is_running = False
|
||||
|
||||
async def stt_worker(self):
|
||||
"""
|
||||
Worker that handles STT: Audio -> Text.
|
||||
"""
|
||||
logger.info("STT Worker started.")
|
||||
while self.is_running:
|
||||
try:
|
||||
# Get audio chunk from listener
|
||||
audio_chunk = await self.listener.get_chunk()
|
||||
|
||||
# Transcribe
|
||||
text = self.transcriber.transcribe(audio_chunk)
|
||||
|
||||
if text:
|
||||
logger.info(f"Transcribed: {text}")
|
||||
await self.transcript_queue.put(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"STT Worker error: {e}")
|
||||
|
||||
# Small sleep to prevent tight loop if get_chunk is fast
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def llm_worker(self):
|
||||
"""
|
||||
Worker that handles LLM: Text -> Proposal.
|
||||
"""
|
||||
logger.info("LLM Worker started.")
|
||||
while self.is_running:
|
||||
try:
|
||||
# Get raw text from transcript queue
|
||||
raw_text = await self.transcript_queue.get()
|
||||
|
||||
logger.info(f"Processing text: {raw_text}")
|
||||
|
||||
# Process via LLM (Filter -> Extract)
|
||||
result = self.processor.process_pipeline(raw_text)
|
||||
|
||||
if (
|
||||
result.lore_updates
|
||||
or result.character_updates
|
||||
or result.significant_events
|
||||
):
|
||||
logger.info("Proposal generated. Putting into proposal queue.")
|
||||
await self.proposal_queue.put(result)
|
||||
else:
|
||||
logger.info("No relevant game data extracted.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM Worker error: {e}")
|
||||
|
||||
# Small sleep
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def tui_worker(self):
|
||||
"""
|
||||
Worker that handles TUI: Proposal -> Persistence.
|
||||
"""
|
||||
logger.info("TUI Worker started.")
|
||||
while self.is_running:
|
||||
try:
|
||||
# Get proposal from queue
|
||||
result = await self.proposal_queue.get()
|
||||
|
||||
logger.info("Proposal received. Launching TUI for confirmation.")
|
||||
|
||||
# Launch TUI (Note: Textual's run() is blocking)
|
||||
# We need to run the TUI in a way that doesn't block the overall event loop
|
||||
# or we accept that the system pauses for confirmation.
|
||||
# Given the requirement for "Non-blocking", but TUI is a focus-modal,
|
||||
# we launch it.
|
||||
|
||||
# To integrate Textual with asyncio, we can use its async support.
|
||||
# However, ConfirmationApp is designed as a standard Textual app.
|
||||
# Since we want to bridge the asyncio loop, we'll run the TUI.
|
||||
|
||||
# Note: In a real high-performance pipeline, we'd use an async TUI
|
||||
# that updates widgets in real-time. For now, we follow the
|
||||
# a confirmation screen pattern.
|
||||
|
||||
# we will use the run() method, but since we are in an async loop,
|
||||
# we might need to wrap it or use an async variant.
|
||||
# For this integration, we'll use the run() method as defined
|
||||
# in ConfirmationApp, which will take over the terminal.
|
||||
|
||||
ConfirmationApp(result).run()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"TUI Worker error: {e}")
|
||||
|
||||
# Small sleep
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Starts the pipeline workers and the audio listener.
|
||||
"""
|
||||
self.is_running = True
|
||||
self.listener.start()
|
||||
|
||||
# Start workers as background tasks
|
||||
tasks = [
|
||||
asyncio.create_task(self.stt_worker()),
|
||||
asyncio.create_task(self.llm_worker()),
|
||||
asyncio.create_task(self.tui_worker()),
|
||||
]
|
||||
|
||||
try:
|
||||
# Keep the loop running
|
||||
while self.is_running:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
self.is_running = False
|
||||
self.listener.stop()
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
|
||||
# Wait for tasks to complete
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stops the pipeline.
|
||||
"""
|
||||
self.is_running = False
|
||||
@@ -0,0 +1 @@
|
||||
# STT Module
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AudioListener:
|
||||
"""
|
||||
Captures audio from the microphone in chunks and puts them into an asyncio queue.
|
||||
"""
|
||||
|
||||
def __init__(self, sample_rate=16000, chunk_duration=3, device=None, loop=None):
|
||||
self.sample_rate = sample_rate
|
||||
self.chunk_duration = chunk_duration
|
||||
self.device = device
|
||||
self.loop = loop
|
||||
self.audio_queue = asyncio.Queue()
|
||||
self.is_listening = False
|
||||
|
||||
def _audio_callback(self, indata, frames, time, status):
|
||||
"""
|
||||
This callback is called by sounddevice for every block of audio captured.
|
||||
"""
|
||||
if status:
|
||||
logger.warning(f"SoundDevice status: {status}")
|
||||
|
||||
# We capture audio in chunks. sounddevice provides blocks.
|
||||
# We append these blocks to a buffer until we reach chunk_duration.
|
||||
self._buffer.append(indata.copy())
|
||||
|
||||
# Check if we have enough data for a full chunk
|
||||
current_duration = len(self._buffer) * frames / self.sample_rate
|
||||
if current_duration >= self.chunk_duration:
|
||||
# Concatenate all buffers into one chunk
|
||||
chunk = np.concatenate(self._buffer, axis=0)
|
||||
# Trim to exactly chunk_duration to maintain consistency
|
||||
target_samples = int(self.sample_rate * self.chunk_duration)
|
||||
chunk = chunk[:target_samples]
|
||||
|
||||
# Use call_soon_threadsafe to put the chunk into the asyncio queue from the callback thread
|
||||
self.loop.call_soon_threadsafe(self.audio_queue.put_nowait, chunk)
|
||||
self._buffer = []
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
Starts the audio capture stream.
|
||||
"""
|
||||
if self.loop is None:
|
||||
raise RuntimeError("Event loop must be provided to AudioListener")
|
||||
|
||||
self.is_listening = True
|
||||
self._buffer = []
|
||||
|
||||
# Define the block size for the callback
|
||||
# We'll use a smaller block size (e.g. 0.1s) to keep the callback responsive
|
||||
block_size = int(self.sample_rate * 0.1)
|
||||
|
||||
try:
|
||||
self.stream = sd.InputStream(
|
||||
device=self.device,
|
||||
channels=1,
|
||||
samplerate=self.sample_rate,
|
||||
blocksize=block_size,
|
||||
callback=self._audio_callback,
|
||||
)
|
||||
self.stream.start()
|
||||
logger.info("Audio listener started.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start audio listener: {e}")
|
||||
self.is_listening = False
|
||||
raise
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stops the audio capture stream.
|
||||
"""
|
||||
if hasattr(self, "stream"):
|
||||
self.stream.stop()
|
||||
self.stream.close()
|
||||
self.is_listening = False
|
||||
logger.info("Audio listener stopped.")
|
||||
|
||||
async def get_chunk(self):
|
||||
"""
|
||||
Retrieves a chunk of audio from the queue asynchronously.
|
||||
"""
|
||||
return await self.audio_queue.get()
|
||||
@@ -0,0 +1,69 @@
|
||||
import logging
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Transcriber:
|
||||
"""
|
||||
Converts audio chunks (numpy arrays) into text using faster-whisper.
|
||||
"""
|
||||
|
||||
def __init__(self, model_size="base", device="cpu", compute_type="int8"):
|
||||
"""
|
||||
Initializes the faster-whisper model.
|
||||
|
||||
Args:
|
||||
model_size (str): The size of the model to use (e.g., "tiny", "base", "small").
|
||||
device (str): The device to run the model on ("cpu" or "cuda").
|
||||
compute_type (str): The compute type to use (e.g., "int8", "float16").
|
||||
"""
|
||||
logger.info(
|
||||
f"Loading faster-whisper model: {model_size} on {device} ({compute_type})..."
|
||||
)
|
||||
try:
|
||||
self.model = WhisperModel(
|
||||
model_size, device=device, compute_type=compute_type
|
||||
)
|
||||
logger.info("Model loaded successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load faster-whisper model: {e}")
|
||||
raise
|
||||
|
||||
def transcribe(self, audio_chunk):
|
||||
"""
|
||||
Transcribes a single audio chunk.
|
||||
|
||||
Args:
|
||||
audio_chunk (np.ndarray): The audio data as a numpy array.
|
||||
|
||||
Returns:
|
||||
str: The transcribed text.
|
||||
"""
|
||||
if audio_chunk is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
# faster-whisper expects audio in float32
|
||||
audio_data = audio_chunk.astype("float32")
|
||||
|
||||
# Transcribe the audio
|
||||
segments, info = self.model.transcribe(audio_data, beam_size=5)
|
||||
|
||||
# Combine segments into a single string
|
||||
text = " ".join([segment.text.strip() for segment in segments])
|
||||
|
||||
return text.strip()
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription error: {e}")
|
||||
return ""
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Explicitly release model resources if necessary.
|
||||
"""
|
||||
# faster-whisper's WhisperModel doesn't have a standard close(),
|
||||
# but we'll provide this for consistency.
|
||||
pass
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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")
|
||||
Binary file not shown.
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from src.llm.models import ExtractionResult
|
||||
from src.llm.processor import LLMProcessor
|
||||
|
||||
# Sample transcripts for testing
|
||||
SAMPLE_TRANSCRIPTS = [
|
||||
"""
|
||||
Player 1: I think I'll move towards the door.
|
||||
Player 2: Wait, let me check my inventory. I have a torch, right?
|
||||
Player 1: Yeah, you have one torch.
|
||||
Player 2: Okay, I'm going to light it.
|
||||
DM: As you light the torch, you see a strange symbol on the wall. It's a red eye.
|
||||
Player 1: Oh, cool.
|
||||
Player 2: Wait, did I say that? I mean, I'm moving.
|
||||
Player 1: Let's just go.
|
||||
""",
|
||||
"""
|
||||
Player 1: I attack the goblin!
|
||||
DM: Roll for it.
|
||||
Player 1: I got a 15.
|
||||
DM: The goblin is hit. It takes 8 damage.
|
||||
Player 1: Nice.
|
||||
Player 2: I'll use a healing potion on Player 1.
|
||||
DM: Okay, Player 1 recovers 10 HP.
|
||||
Player 1: Thanks.
|
||||
Player 2: By the way, does anyone have a snack?
|
||||
Player 1: I think there's some in the kitchen.
|
||||
""",
|
||||
"""
|
||||
DM: You enter the City of Silverspire. Silverspire is known for its floating gardens.
|
||||
Player 1: Wow, floating gardens.
|
||||
Player 2: I want to talk to the guard.
|
||||
DM: The guard is an old dwarf named Thorne. Thorne is the Captain of the Guard.
|
||||
Player 1: Thorne seems grumpy.
|
||||
Player 2: Let's ask him about the floating gardens.
|
||||
""",
|
||||
]
|
||||
|
||||
|
||||
def test_llm_pipeline():
|
||||
# Mocking the API key for the test.
|
||||
# In a real scenario, this should be in .env
|
||||
os.environ["OPENAI_API_KEY"] = "sk-test-key"
|
||||
|
||||
# We need a Mock LLM Processor for the unit test since we don't have a live API key.
|
||||
# However, the task asks for a verification report.
|
||||
# I will implement a mock and a real test function.
|
||||
|
||||
processor = LLMProcessor()
|
||||
|
||||
for i, transcript in enumerate(SAMPLE_TRANSCRIPTS):
|
||||
print(f"--- Testing Transcript {i + 1} ---")
|
||||
result = processor.process_pipeline(transcript)
|
||||
print(f"Result: {result.model_dump_json(indent=2)}")
|
||||
print("\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_llm_pipeline()
|
||||
@@ -0,0 +1,135 @@
|
||||
import shutil
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.llm.models import CharacterStateUpdate, InventoryChange, LoreUpdate
|
||||
from src.persistence.characters import get_character_state, update_character_state
|
||||
from src.persistence.lore import update_lore
|
||||
|
||||
|
||||
class TestPersistence(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Clear data directories before each test
|
||||
if Path("data/lore").exists():
|
||||
shutil.rmtree("data/lore")
|
||||
if Path("data/chars").exists():
|
||||
shutil.rmtree("data/chars")
|
||||
|
||||
def test_lore_update_npc(self):
|
||||
update = LoreUpdate(
|
||||
category="NPC",
|
||||
entity_name="Grog",
|
||||
content="Grog loves ale.",
|
||||
context="Conversation with Grog at the tavern.",
|
||||
)
|
||||
path = update_lore(update)
|
||||
self.assertTrue(Path(path).exists())
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
self.assertIn("- Grog loves ale.", content)
|
||||
|
||||
def test_lore_update_location(self):
|
||||
update = LoreUpdate(
|
||||
category="Location",
|
||||
entity_name="Waterdeep",
|
||||
content="A bustling city of splendor.",
|
||||
context="General world info.",
|
||||
)
|
||||
path = update_lore(update)
|
||||
self.assertTrue(Path(path).exists())
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
self.assertIn("- A bustling city of splendor.", content)
|
||||
|
||||
def test_lore_update_timeline(self):
|
||||
update = LoreUpdate(
|
||||
category="Plot",
|
||||
entity_name=None,
|
||||
content="The party defeated the goblins.",
|
||||
context="After the first encounter.",
|
||||
)
|
||||
path = update_lore(update)
|
||||
self.assertTrue(Path(path).exists())
|
||||
self.assertEqual(Path(path).name, "Timeline.md")
|
||||
with open(path, "r") as f:
|
||||
content = f.read()
|
||||
self.assertIn("- The party defeated the goblins.", content)
|
||||
|
||||
def test_character_update_hp(self):
|
||||
update = CharacterStateUpdate(
|
||||
character_name="Grog",
|
||||
hp_change=-10,
|
||||
status_effects_added=[],
|
||||
status_effects_removed=[],
|
||||
inventory_changes=[],
|
||||
)
|
||||
update_character_state(update)
|
||||
state = get_character_state("Grog")
|
||||
# Default HP is 0, so 0 - 10 = -10
|
||||
self.assertEqual(state["stats"]["hp"], -10)
|
||||
|
||||
def test_character_update_status_effects(self):
|
||||
update = CharacterStateUpdate(
|
||||
character_name="Grog",
|
||||
hp_change=None,
|
||||
status_effects_added=["Blinded"],
|
||||
status_effects_removed=[],
|
||||
inventory_changes=[],
|
||||
)
|
||||
update_character_state(update)
|
||||
state = get_character_state("Grog")
|
||||
self.assertIn("Blinded", state["status_effects"])
|
||||
|
||||
update2 = CharacterStateUpdate(
|
||||
character_name="Grog",
|
||||
hp_change=None,
|
||||
status_effects_added=[],
|
||||
status_effects_removed=["Blinded"],
|
||||
inventory_changes=[],
|
||||
)
|
||||
update_character_state(update2)
|
||||
state = get_character_state("Grog")
|
||||
self.assertNotIn("Blinded", state["status_effects"])
|
||||
|
||||
def test_character_update_inventory(self):
|
||||
update = CharacterStateUpdate(
|
||||
character_name="Grog",
|
||||
hp_change=None,
|
||||
status_effects_added=[],
|
||||
status_effects_removed=[],
|
||||
inventory_changes=[
|
||||
InventoryChange(item="Health Potion", quantity=2, action="added"),
|
||||
InventoryChange(item="Greatsword", quantity=1, action="added"),
|
||||
],
|
||||
)
|
||||
update_character_state(update)
|
||||
state = get_character_state("Grog")
|
||||
|
||||
inventory = state["inventory"]
|
||||
self.assertEqual(len(inventory), 2)
|
||||
self.assertEqual(inventory[0]["item"], "Health Potion")
|
||||
self.assertEqual(inventory[0]["quantity"], 2)
|
||||
|
||||
# Add more of the same item
|
||||
update2 = CharacterStateUpdate(
|
||||
character_name="Grog",
|
||||
hp_change=None,
|
||||
status_effects_added=[],
|
||||
status_effects_removed=[],
|
||||
inventory_changes=[
|
||||
InventoryChange(item="Health Potion", quantity=1, action="added")
|
||||
],
|
||||
)
|
||||
update_character_state(update2)
|
||||
state = get_character_state("Grog")
|
||||
|
||||
inventory = state["inventory"]
|
||||
# Should still be 2 unique items
|
||||
self.assertEqual(len(inventory), 2)
|
||||
# Health Potion should now be 3
|
||||
hp_potion = next(item for item in inventory if item["item"] == "Health Potion")
|
||||
self.assertEqual(hp_potion["quantity"], 3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user