From 58bab75bb5d8e39f006ccb582a1d84bc9cfac378 Mon Sep 17 00:00:00 2001 From: charles Date: Tue, 26 May 2026 19:51:48 -0700 Subject: [PATCH] Improve audio capture and LLM integration - Implement Silero VAD for dynamic audio chunking - Add support for Ollama and vLLM backends - Harden extraction prompts for strict JSON output - Refactor TUI worker to handle proposals asynchronously --- .env | 2 + src/llm/__pycache__/processor.cpython-314.pyc | Bin 5661 -> 6671 bytes src/llm/__pycache__/prompts.cpython-314.pyc | Bin 1132 -> 2462 bytes src/llm/processor.py | 34 ++++- src/llm/prompts.py | 51 ++++++- .../__pycache__/orchestrator.cpython-314.pyc | Bin 7517 -> 7200 bytes src/pipeline/orchestrator.py | 40 +---- src/stt/__pycache__/listener.cpython-314.pyc | Bin 4387 -> 8469 bytes src/stt/listener.py | 139 +++++++++++++++--- src/ui/__pycache__/tui.cpython-314.pyc | Bin 13402 -> 18000 bytes src/ui/tui.py | 102 +++++++++++-- 11 files changed, 290 insertions(+), 78 deletions(-) diff --git a/.env b/.env index 2a76d88..ee04edb 100644 --- a/.env +++ b/.env @@ -2,5 +2,7 @@ OPENAI_API_KEY=no-key-required OPENAI_BASE_URL=https://vllm.tipsy.codes/v1 LLM_MODEL=Intel/gemma-4-31B-it-int4-AutoRound +#LLM_BACKEND=ollama +#LLM_MODEL=gemma:2b WHISPER_MODEL=base AUDIO_DEVICE_ID=None diff --git a/src/llm/__pycache__/processor.cpython-314.pyc b/src/llm/__pycache__/processor.cpython-314.pyc index ee70c986f302aebe99ad2bbfc641e8d0596a7f4e..e142040cfc71f6953571552f16123163845b6abc 100644 GIT binary patch delta 2328 zcmb_eT})I*6rOu`@7=$VCDLs(&4T5jy}5&__2>rRlLTOz!~OfS`^rebg3qRfP55#Qu!p z-}euHXUczbXlZJcqvcw*ijZ|vsVRS3atkW5OV3sunhg%tFZ^XgFJSpUBANb|Nbls&-OEdwhrfww0;oU@ZCrT9&>;^(5KpT?1e8pPRxSxw7M;0-~^*Py;2_tX_X~0 z2!>bgxXe3GkO{#n#Q7%)U zgaY$Xbs05n=<4zhwtBpszV0@e4M)UKK*D`67&sn?=Z?o>k;cl(V0btX91llhja60d z8h7Pnm8_l&29L|;o`b$_&jC!(>l=Jk=#ouogXiFZ!A{>{7Y#{#(1Ibp(+6P~I;D^l zlLA30E``RN(|CF3a8MFMG3O{A9108{6GJ1;INj*%mUSdJe@~mQE6$EZV!Pd8mrka` zQCTB~CM6gS$*M6iCNshCNfAO8FCw!kGFj)F8Wtll3D037PzuGM6%V_#vN|dTM>nEs z28vzQq!*S=qf#gk92^Qn#laIWC>v9HAR-MO6Hm#M7$r$5!Oe*qDr9#}yYMBL0jqY9 zVY{^R!p=(-7b>pif1zFE8saHAkme_+wok)Ge4aI}90ug9;&ub;kl zIx$_lf3dXvoL^!7?{13MtUsydbo>u?=SoiTN^a>YqslYQw!PPx)B{WQ<+3I7_9P3e zHmqjlE!(^aoA;BUD-&}QS3+~4&yUuSOYSP5F&Sq~WH#S9c}MyN z-YdM9k>LGHT=xoR{XNOx;oqXTa>2q&cAo~^)fjv()m@(V?NHs_!C)DE$$pDw49xsl z%_>DzqqlY2sbX|RS6fquy6*~Sa(s7M@KcqR1Z31_%*wYxRLih#i&X#wxb!Y6VLdL`Da ztbuJz$s+={dy}NQzt69zknCzWeRZ)20I3t@$zMf_#v=Pb`l-a1nSOpb`_mr)WYK1# zLQ`{(O29Al^NU_d3o3yXn0~5~uAl{tpsl0?ouJ2Ps7rrfBJ?J1?m71xKro`qrlXVz zJvHs4EXZx%MVZl?=F(hagl%Dq@Hpu=Jq%WK)f}MXwJ2fEJ=BXYBDtebeCvc5jX7iC z_3K$tQ33bj?ZH|CN@nV@B&%bmBBE@Vh=xOh;h_m}I0hTAeLrl(7_TR++l6$7Fzv7? zO^PGVQ5ZhH!Q34$C(}@mrQAjm4T}jh6KEmuGCFU`Z1oVal>k|sJOF`_C?qGYl!!}{ z%B7zeyaaj)D09^#fmi^RqlcDWR>|xisKuJg*>xp9nTSE zu_&SSW66~+v%|#dM^7_6)D)`E^x!IwXTD bk8F*UfWEioP-Ez!t;~FaqMDunf{M$Z1Tyxe delta 1246 zcmZvc%WoS+7{F)t<@Yn**iP-lsaq$;&I8#^NJDGct)rAUPPz$|qJ|hZU80SGZN?S? zOSCzFK!OU1X%9xAs-jYlP~{ImYCxd259+km_kY%O);2t%a7RW8KiUp(303|*#615W*WROzm}|EpC}R4Bi+HrVB1KW$v=Nn( z__}juHebwMC}n3al&10vx~sj;W#{sx*Ofw?BQEeGZh&qn6sAklGw1V#rnFLP45X{Y zxI|bEbgo+0O={&`jZ`Z-zoIsDX?aakD-GfSbzIPey1Mq(i&%!ZE|@?WgF!zvCnm4J zVjVs2LjLf!@7PmcX3LlPirj9tnzygDu5AsTebSTJmcviw+?JfX>%G@?r|Vw#o$jsS z?33QybCLI3KM_8&?U?D8mTr**Alet!N4RI>vGoAsKKi5mIF8U~_K~q9sB}yIy=Ar5 z(BQmcjcAnyTm|xn$VrA611q3zCThJ_t<+T=tMsBH1pz;Dc(A!2lyOYF^gH)SJW79X zKJ&lIoLHxkF{b1X<7MckYYHq}?wh!u-gUnm&49KZ0vxQPeH0dByA1c|cG&0FGt;o7 z2ktQbnRa@jbjG8NaSE>pi*|4^#t^4)il_*e@DMl1DUxCu!irh30Ij3#)~InyNU>3q z_YG{Ps`m_b(r>&8?4ZAR`+~Nblz}c#jIV|@G|q>ShT|^k>L{NgFTpM(!N5{Q1^~Ly zc)zCVw$*yIQmS5ARhJuN5abhN2v}?;X{IAchkZ$z-JL`jQVeN^Q}i95FE_%bqYOp` z#IVxG@hhM;``?es=kTkD-nCIrd`wZ9Fmi zF*+RdS)sT_h4;_WnV=jEgQ+e-R<+6sfky-pRyo3cQ(dao)Jj>S9|lk3*XZ}b0DhBh Q2m4)@F&^JTj7ER|0~kyUb^rhX diff --git a/src/llm/__pycache__/prompts.cpython-314.pyc b/src/llm/__pycache__/prompts.cpython-314.pyc index b8c3acfecdf5766c7a2184f9ca470ba97b32ce10..8d7af04a588e06e0a39e29c2633ebbda265f6ebf 100644 GIT binary patch literal 2462 zcmbVO%Wm676qWPngFv$j5ClbW!2*$mN^~AOwP@hjN}|M;1W9Qc1c5LnhawY_!_Ext zutk9WPXDIp(%b$(fc!wuB`H~P+%BqxK+ep)bLO0L@9?{8UpX!Orr%$Gee26s>u-Kp z{kEEi57+SUdu!UVt?8ECnqK<0)wwK6Ogk@BK|)Isw0~=#9*I=ap)gkJwjp6mY^=zv zM~~uTnPHLDA~U{@bK4~?Q+15UBq3GUo(g+@B((4mOxxq}UWf2*@z$k8Es-hqMM6Pr z`~rzpW@uY_ORr1q=aB@2*ic$haQF88cBe}s3n)`rPs>Ce0|Q=SBRywa*u1c$vZN#% zAu8M0Hi!kbDgX-6OC07bT;c%Mc+OKP^D0p3co}CvWO6CqNNw7bA;M4`@_bp*l51%Y zhh3_ev*UZtz5*mmA_%ZsNxn)`tQ5K=rO6a&Ds(K3M^6f4DU1`Qg#lan)_RV(|M@5E z8(Hy!bRsE#mD(eyqiLxC669D#2xWrFSY7zG&@!MvSV5syDb%TV!Qdv&K@(Gfj6}A? z5s~UlD@bfHw*7e0MPgwKLoy7dZ%r5HX4;f4I~ApzBapMLfKbudU&1pP3ctJny z9t`*C$!t9GFw?}vnV%E^i@Hg37i{DSxN5cq>#mJdv5c%sN5Jz2YG0V+jVui*Xu*Pr zj9fmB+a-6+$)e%}L+m5BwvNl$h9N|U^BYgbS*UbcAz+0&}WM^hvZ zD{y^hS_2Xlv80Y(LtHwEZ3GG-VqHl{$>{pd9j{@ePrDU-QehJWI}d~(tx_C5o6RW_ z$FlDbQ4a*dNlUoJ?b9|q%JNei25FcABmm01j&&wvvbsHnAvdG|opT@4O)?&ADwwi_q>4=Pr& zd~%8ri`|@Hy-x!<$`=?yjWXyM@R!^^Rn8t4Nr%P8B)6l-lP07t*+o;b3-luya5Ni_ zx2bh7or{;(P5i$C)-9kxW;T=8UNW_r+}O&L>*$vP4I2XTGuQ@px8vLctxY9uvf03E zj8(spK^-Rn9r3;7tIS8|-;{`tCCJ3f429zJ<&eWXSaB>9m2(?LDGc6M@(jbKV|sUPud)4$EnZgJR;NC<;>-yf zYc3N1<*RdNSgTq?2ThW4q0#*Kvd1?9-&4$lkv@7OfmM7)__zvHHSFs$w-qz5oOh1% z0MQyf5Vt)woojvD^Uct7AI}+eE}h;Sjfb;bJl!A5P|{HwHiB zn%SEV$D@yD{%C#RFQZd$CsL{0;UY|=*~xc;Ea*it$?-L?W3<1MB&X`gb-3h!wELtvAtmLSi2jdR2;$;4HuxI z<`x_SQBtL1GTj@!`QDrNgWpMdnog5}%nXN5f8)>eV>p_Q`mf>7>)p~CYOJG>Cua(C z_LSq^kc+llL%TH`tzccrzSYsPgI@45BG!Zz`^IGGh`WX|F&lJ^E-0wvT;ZnHUKf!( z9;1#zoijf}IB2(Yb^%;o!RS_3a$S>_8dU}{*hp=_f<&XzQqF9*4Tq?b3x(Sk0fZb6 k9I4;zoy;UKxh8a;3L8lEZhYBqCU5)6@y_?)$nVPM|3Ju9#{d8T diff --git a/src/llm/processor.py b/src/llm/processor.py index df69d56..ee241d2 100644 --- a/src/llm/processor.py +++ b/src/llm/processor.py @@ -22,10 +22,34 @@ class LLMProcessor: :param base_url: OpenAI-compatible base URL (e.g., for vLLM). :param model: The model to use for processing. If None, it looks for LLM_MODEL in environment variables. """ - 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"), - ) + backend = os.environ.get("LLM_BACKEND", "openai").lower() + + if backend == "ollama": + # Ollama's OpenAI-compatible API + final_base_url = base_url or "http://localhost:11434/v1" + final_api_key = api_key or "ollama" + elif backend == "vllm": + # Remote vLLM server + final_base_url = base_url or os.environ.get("OPENAI_BASE_URL") + final_api_key = api_key or os.environ.get("OPENAI_API_KEY") + else: # default to openai + final_base_url = base_url or os.environ.get("OPENAI_BASE_URL") + final_api_key = api_key or os.environ.get("OPENAI_API_KEY") + + try: + self.client = OpenAI( + api_key=final_api_key, + base_url=final_base_url, + ) + # Simple connectivity check for local backends + if backend == "ollama": + # We can't easily check connectivity without making a call, + # but we can ensure the client is initialized. + pass + except Exception as e: + print(f"Error initializing LLM client for backend {backend}: {e}") + raise + self.model = model or os.environ.get("LLM_MODEL", "gpt-4o") def _call_llm( @@ -67,6 +91,7 @@ class LLMProcessor: print(f"LLM Processor (Extract): Calling extraction for: {filtered_text}") try: # Using standard chat.completions.create with JSON mode for better compatibility with vLLM + print("LLM Processor (Extract): Sending request to backend...") response = self.client.chat.completions.create( model=self.model, messages=[ @@ -76,6 +101,7 @@ class LLMProcessor: response_format={"type": "json_object"}, extra_body={"include_reasoning": False}, ) + print("LLM Processor (Extract): Response received from backend.") import json diff --git a/src/llm/prompts.py b/src/llm/prompts.py index 42f9bf4..957beba 100644 --- a/src/llm/prompts.py +++ b/src/llm/prompts.py @@ -11,10 +11,51 @@ 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. +DO NOT THINK. -Be precise. If no relevant information is found, return empty lists. +CONSTRAINTS: +- OUTPUT ONLY VALID JSON. +- DO NOT include any commentary, explanations, or "thought" blocks. +- DO NOT include any keys other than "lore", "character_state", and "events". +- If no relevant information is found, return empty lists for all keys. +- If a character name is not specified (e.g., "Your character"), use "Player Character". + +Strict Output Format: +Return a JSON object with exactly these keys: +1. "lore": A list of objects. Each object MUST have: + - "category": (string) 'NPC', 'Location', 'WorldBuilding', or 'Plot' + - "entity_name": (string) The name of the NPC, Location, or entity + - "content": (string) The actual lore fact or description +2. "character_state": A list of objects. Each object MUST have: + - "character_name": (string) Name of the character + - "hp_change": (integer, optional) Change in HP + - "status_effects_added": (list of strings) + - "status_effects_removed": (list of strings) + - "inventory_changes": (list of objects with "item", "quantity", "action") +3. "events": A list of strings. Each string should be a concise description of a significant plot development. + +Example Output: +{ + "lore": [ + { + "category": "NPC", + "entity_name": "Thorne", + "content": "A gruff dwarf who runs the local tavern." + } + ], + "character_state": [ + { + "character_name": "Grog", + "hp_change": -10, + "status_effects_added": [], + "status_effects_removed": [], + "inventory_changes": [] + } + ], + "events": [ + "The party discovered the secret entrance to the crypt." + ] +} + +Be precise. Return only the JSON object. """ diff --git a/src/pipeline/__pycache__/orchestrator.cpython-314.pyc b/src/pipeline/__pycache__/orchestrator.cpython-314.pyc index 94c2e6cedbf08f669dd92eed82905a3ac6f36a75..377ff5d1f10894d4e0d74cd0bde5e26f5ab3e8a0 100644 GIT binary patch delta 541 zcmX9(O=}ZT6us}g%#4#6(=hF%(`M36Z3-PEtw}1{T8M+O(i&1p{MZEAP%ETDGrr>I z#vq7xCGr;H&V`FEm5TfM1=doCgi2kx4l&SzUHD$Ti^I9++k_x}mZM=3x37l*k$Zc!CG}#C7oy9-C3Hx5 zr0VR1N#**pSQ8BX1g-mH8v#=9SZB@6*I|J#ap4rtif7^tc#~YXVi5D?%7QJ^p4FZfjH58DL;0oq) zpd!IF&6_4c%<`rT5=_X#n39AvO}yFdkA1rY<=A6uQE3tWO3Px)r_2y%E8D-VAuP{{ zM+UgqPwIYyCF3u(9GB>I0dwq2yf`+=>Cr1_z#-NprwtrBwkeHuCL9iVq)FM%ARD*8 ZqD6LKC(#lMB^G)fA>{l4fz;#y{{WDbg9rcs delta 614 zcmXw!PfQa*6vp4|cDIEs?QUr)UApW_OR#N9WCK`e#7N~}Q%fc231bL#GGb0E=6hg_ z88HJ7jKC+)SDD*E+q>NI*s5t{mpXmt-^3-=7 z?U1UkhrD-5_rhJ5zw42fZZ>qYrH?oC@ur??g;H`SkMDtfl}G1$+EGqgSqk#aB~Y;4-4cjRa2l{7`ovq?Nd9;#e}EAO=-xkZOp+-8 diff --git a/src/pipeline/orchestrator.py b/src/pipeline/orchestrator.py index b5f54a2..aea3f2a 100644 --- a/src/pipeline/orchestrator.py +++ b/src/pipeline/orchestrator.py @@ -88,39 +88,13 @@ class PipelineOrchestrator: 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) + try: + # Launch TUI exactly once. + # Pass the proposal queue to the app. + app = ConfirmationApp(proposal_queue=self.proposal_queue) + await app.run_async() + except Exception as e: + logger.error(f"TUI Worker error: {e}") async def run(self): """ diff --git a/src/stt/__pycache__/listener.cpython-314.pyc b/src/stt/__pycache__/listener.cpython-314.pyc index 006facc5beb3cb64f66dc14ee3090178cfc8d32f..393e9fd820b8299d21a3f8c8cdac0567f37e145a 100644 GIT binary patch literal 8469 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 p*bQIDDuGZT0Ie7)in= 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] + # 1. Update ring buffer for pre-padding + # We overwrite the oldest data in the ring buffer + num_samples = len(audio_data) + for i in range(num_samples): + self._ring_buffer[self._ring_buffer_idx] = audio_data[i] + self._ring_buffer_idx = ( + self._ring_buffer_idx + 1 + ) % self.pre_padding_samples - # Flatten to 1D array (samples,) as expected by faster-whisper - chunk = chunk.flatten() + # 2. Run VAD + # Convert to torch tensor + tensor_input = torch.from_numpy(audio_data) + if torch.cuda.is_available(): + tensor_input = tensor_input.cuda() - # 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 = [] + with torch.no_grad(): + # The model expects (batch, samples) + # Silero VAD expects frames of 512, 1024, or 1536 for 16kHz + # Since we use block_size=512, we are good. + probability = self.model(tensor_input.unsqueeze(0), self.sample_rate).item() + + # 3. State-based Chunking Logic + if probability > self.vad_threshold: + if not self.is_collecting: + # Start Detection: Transition to COLLECTING + logger.debug("Speech detected. Starting collection.") + self.is_collecting = True + + # Pre-padding: Append the ring buffer in the correct order + padding = np.roll(self._ring_buffer, -self._ring_buffer_idx) + self._collection_buffer.append(padding) + + # Reset silence counter + self.silence_samples = 0 + self._collection_buffer.append(audio_data) + + elif self.is_collecting: + # We are in COLLECTING state but current frame is silence + self._collection_buffer.append(audio_data) + self.silence_samples += num_samples + + # End Detection: Silence lasted longer than threshold + if self.silence_samples >= self.max_silence_samples: + logger.debug("Silence detected. Flushing chunk.") + self._flush_buffer() + # Max Chunk Size: Force flush + elif sum(len(b) for b in self._collection_buffer) >= self.max_chunk_samples: + logger.debug("Max chunk size reached. Force flushing.") + self._flush_buffer() + + else: + # IDLE state, just waiting for speech + pass + + def _flush_buffer(self): + """ + Concatenates the collection buffer and puts it into the asyncio queue. + """ + if not self._collection_buffer: + return + + chunk = np.concatenate(self._collection_buffer).flatten() + self.loop.call_soon_threadsafe(self.audio_queue.put_nowait, chunk) + + # Reset state + self._collection_buffer = [] + self.is_collecting = False + self.silence_samples = 0 def start(self): """ @@ -56,11 +149,11 @@ class AudioListener: raise RuntimeError("Event loop must be provided to AudioListener") self.is_listening = True - self._buffer = [] + self._collection_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) + # Define the block size for the callback. + # Silero VAD v4 recommends 512 samples for 16kHz. + block_size = 512 try: self.stream = sd.InputStream( @@ -71,7 +164,7 @@ class AudioListener: callback=self._audio_callback, ) self.stream.start() - logger.info("Audio listener started.") + logger.info("Audio listener started with VAD-based chunking.") except Exception as e: logger.error(f"Failed to start audio listener: {e}") self.is_listening = False diff --git a/src/ui/__pycache__/tui.cpython-314.pyc b/src/ui/__pycache__/tui.cpython-314.pyc index 057531bc9d4d226426d682fa6cdcf13939fadaa9..1064f1ce19d791b73350879da1e7a161ff740c2c 100644 GIT binary patch delta 7219 zcmbtZdu&_Rc|Vtzm(MGT5-ExlDUy3MQ6scsoe#L3k2xAKL#02s-i8{ zec!pHBrCy`VaL|*p2v4y-}zqWP#wMZhG1J|YqAhXKYrPDR*hV?xhvP9G)_*Cgs_h& z_|@$bqB=zv6%{e6SM&j_6Zc6`gJOsp6(cX}_nD$*#T>OLmZ(*+@;Ygs9BopXqBg}A zZC0A2cEuiTQCgy{N-J+S>~ln&iZj}#v_)Nti`N^%OyndCWIamwm!x}EG(+}1G%!InhuqeT(OYOUw-h>YCX0=dP)RB(j+a6 zljB4Y5=7CRXi`KMDGCWe(I-3+L6H(2?B{|@mlxQqF#d8<7!(q%?IdqXH~{U6jn_DN zO&hOi<~1%})6Q$`3AfUc6qME^*(4|qSe{pH-c>A|&e3yeT-J_KDLuyC(G^%#-^KdH zb@1)DH7*_!Ad|F6uvXmO)330#Qowgucvvy9 ztQu&xJeF5fTZtA+#d12IdUY&2mJRDEa)GuYjWJ!mP#DP-Rp~`+V`ruFr#C}GIRfyj zaWYGqrLHFkKexN9p6~b2d`MDQQ76vqC&UMBanoGyxlG*3N`^M}uZB+d z|3)C#Sh~-_#v{f}0gzspXKpMmGU8*91`>PxJ4j)(DwW4dS*n^+sazqaq*4?)PQ5H> zj8*iizMRdUu2(_5YEGp_^67Fpl`7-vL!r<(4Pu90Y;`X>>_}r!&gD};0$l#YLd>>H z#;VCZW$KuA`QETj`}?L$-sxC;LO*5l{lePvNVeDHV0EJ$tjQ~?@`~$*TJNT6@1`kv z^R(8quw{F-cl(sQl&p?_%d(jJ(Hjms{!MhdN?9$XkmhcZSBVSyT<7}ilgd}sim zjMCFx-$WPIb^{z_-!yd!J-2R{J`#kB?2_eYn~;grjDQA%$_UK}b_5%O6`=_L=0TAv zBcnq!$lkUt7Y@FA+bSHsA@T*$0ceTc_yQ}*<2daAz`0rCk#-ADI8H9pzO;u~n>@Oo zitIiA>Rq7ENuhvS;wdS_aleaFLYEK|`i3D1kYU^vPasy7hVc08WRI{HIC36_qdmj8 zFO5hXwy`^X0a1vzuzOx73))=FzO4022}4qhI~!#4-(g%nPf6VnvIJ7XxUMe!0kZ!q zggi668-{nYahp{%B}Ha$bHeamQH=Z8o&K&)eZriu#PRGFC79h;U%gww!OKf z-TL{|yxrdHJr3JgG!PUMGK_|juqY+U0r}9}N})eJGLkJRd&7ciP)2Dsor#6rsu|2E zy^tyvvRvd<>&O@_7imhlP|C7`-P0O|*;PxfoGX--bYUb*9qcddy+RASYfl_doulbO zCZA2gRH@P2nbAD_DO^!h>2z^qtW3SUKOHNi&KKz`V2K>1Vm_Z*WX2^LVeXcGA;|W& ztmxrN@y8%a<7C?Iykfg-o2>k;`M!PQgf#8znvkYUo?8{4uU%lAX~<+U|=T~%H;C2x3SZLV4U@c*9Gk0ggK9h%%- z^9@yfL${Ak9C|44dLj}z&@eUYD|8!dgKh^{+PHijsLH4(T%~Q&&=Uf1UHkAMN9tMv zG=v!7?+*!)pS}mR-6Q~;_QF4q2j0wqedICg;vxu2hwB9hUCx4|d0iM64@eeL5`a_! z*Wr}sWQ`14+4~*-_Jr7wvN;FT3F5`7o_1Zl3FWS`{~RIvi-Z`-fkQzujH}_vKr*0% zK2p*hA@Mm2(r5(@(jC#sWMEF?1EdocR?=T6U&KUO7XHV}(pmleeHUnpT1+b7jb8gAKt8 z^7tWmx=4eC?D=5!b%mx!Fo+3ikw!UUiCy&Xp%e(Pcdl?|QQwV~plVjqA~N4?=NziPJkdRP#ov-pEaB&9V6d$L49LyXIU`b*`v6*H)ctA36s?9h_U9 zG=sMN>SKrR6QXO{_Q+mwUNK!Z-Scj^-F4e>&$Fdw-&(bA{mqO7t-paVV99|WI0t8N z;NO<_!P@^ox63Ddt#ii^`Hrw-K>uzV zx+SQ(+bKffZg9gckNA%s5jwDMx4Kwv)v5{x2U>K)9WwZB3>24E;VVE@2Km!exJ%mj zgaA~#*3RJfW`-9vRjwOiphd61<3#x>>HV=+`sHg2@$fc`KCs07`ew&Qfkbc&@b@oZ-QT6I9W&CuN%$Z1N$Js zs6Lorm1KaNAAP&gkT4{S@h)~OvV+%lbB}LGn6S1n+x!>$bvHczs3EC~f8IvSK(%bX zPa~H!EVX{{Vvq2gGT}f$_sYrK0It2C}LZ!Ud|p zr6ZGlou0-T8SLvAT->y(Gf-7F0))WcLDEcK0SP-6o4k2z$`$il;3&D7YiWBXHux~% z@=PwPbwsKik^8RI6V^vAU(FS+y297@*7|l=`*zn{yQ{9<6MH{%%b1c^f9Um3Dz(61 zH86PJJ9P8a2j2A)2R>$(*SPwoogFo2sOk*WoP+nAgV)1QaV2&+_MzK5dEmY~JYyy; z{$CQQrS%h;{a4GIqtHINx8_@0^{u_R>BqbNS-!vfDAa*udLfYW-vH)n+e=cI`m-M zbajO_9t9;JaDf+MFzZP?S4%y%gkTY>P+;iq1Mn+a2<}lx(^)K}Acjq+xB@&IS7tM6 zKReOh-8UC$W;1-fXuKd)G|4qnSJl*Yb$P9OpxQm~&@{+diu;0HKv}<>396VY zC`)tXAD~MauOplz4k->dG{hU?4FBHLYh{>fOSi}TGjIsw)8WB=_q<`u#C*&AKwzcNYa zS;^@UB=!ww4os>p?q+>$9@f(4>NG*gH&5HhK_7Ec$C4|0*bmzF<5L4_;i8miV&iRV zt@s3Gqv+ydZ@a$xQrJQJAx~dJ;6{ZT8eG@{W5Si)Gy)fqGevq~fmz`egueMBCyRT+y zp$*m0hW8G?@2u@OTHSH<{+8raDEV)4>!Vv&+k=9D+LT=PdoUpg1)4V17>5|H{6*B} z0~Yi2!h^zj&tK~f;yo}%F|wr_5H=w^hY$h?%Ni4rf2!fsn4%O56dlI`7PeuLv&+dw zeQOePy234~7A1x>lc~0V#%QC!rHU_C?OM#Q&yak;{?qp{-?4}NN9T@RTsiRI>dGNf z2@Bjg;5wF8lgBLY9|8_s^Z!t&T*l6CAwY~k=-U7>*f*CJ8`jLb9&|XW^jbO(_fWoL z?tHj_@Q0W`L-IcdXoSjKye!!aJZ9wzr7`6hZqUEN;aDvg5FWGH;N`h3ZtbVv!I9p@ zk(PWP`jj1#4jcF>$-O1PmN^rc}=W_k@z?QS1sg+_M$b% zNbN<7A2!2Q?)OnG!v@VLa}FEfR+NHcq1(`%Q*_t#F96C00L^D>F8firdgEs z1;&`WeQ6}0O;dUl2bdSB|8JD137DOLQR8-~Aul&^8ZISS%yG#=dHIZ}yu*6ebQ}1Q zj^B!}Su0fjPY*W``aJ4hHnDq3##bKxuCwj?d+*DGHF;xI-gs}*;fL}OE(q|m0C!Jb zTu%oA9uNGABD|{VUmeRSs{Td5u%wH{!cO)tYxfNHBGqPuQG^kM0>W1jP9l68fh!cs zJbi}^4QyM!8nYN4@Gxfkc165) x%V)RJe}X!&9i$A>N@hes5dNLmJ|ewU(#uv3bvjCV;i&M|$S>i_4H-Vf{{X4H67K*2 delta 3528 zcma)9drVu`89(P-K%e zcqCn=^h*5vo%i`(zw>ePGBuBR9`d*y1ioX@nrF8~uXyU`UxU^Hd6J~)C`nR-CM83P zU|SlMlNQ60R18J5bHYGOChZ!z;#> zQD3s!s80F~e==YMM4xrECRuCL3fVT=U6-sk>La9vw2+iqM^g49mOdG;NQe5ONA3L2{QmG8q=+7IiExn-*OwJi~%;mTAvq za)qp}XEHjT5|1yiF#NI@Kd&}U3w^!;ZP4dq)qTmfq}Jb3L(8?{%g*Jo@r#yQYIwy; z?C#rYcu5W4R-;R5^o^0*vF%H-?YGn&%c|>(BmGOU{#)w6Cr!;4Egz{3EW)!jLEd6- z+g6YF0izmotO!Q6LNg(;5ZO;NYOIf#D z%2I^vKTOEIJ5AN{z2S(R!7LlBuIE>(x@d@BuZk*a%0Ax6uZR7zJRaiNh7iv*`ovi@ z9^ij(v~%yE)n-XKD)Xq!dj>mThPajDg#DUfePD13lR|vyFTK<--5(>wPMS!{1-a?| z%pIMZoz5Cshmn1{pz(kE9@{;Z@R{}#a~eC7DHS#0SJRoDp3dY-g}IrcZrZit+>FLx z6>EjFOh;bN7j+|B%xSEezgNAPdikHKQCi-|<}W zygL7${X_4=7nQ|Tznj`t8%Ss;xNl->ke_cEoEL$rXeYIzQ4S9XQ*@2#OqEVeY6UH4 zXwwf4-P5o{$BT-9@hamGFo^HkZ+8zbbq}wI)^8wuu$M((rXUZ#9QCOuAsZG5*!e;% z;DMZ9cQgUI;J<1Q&@{iEsNwIlSMfJvwUqFm#|}ZfN#kDL(^^9%-q9MA<+Q|?8Up-C z>m;@CcUyxHJ`y+@W++yW!eXwC|EIN;s=O3O>KTalPzTDqKi+FWSMguRe{gs#;WuSg zI>i!jOJRMsA0?4;V<=T4KS6k#*zfhWP^66sGd??vCy)_nT#%z}M~I z13Q#=0fL9?^2;4fs*tqlm-tUR{vxzHG;+;S<~cO%I@!q#Ox_NdCZJ&L}F-S=xN)HLNhPzS1MI?|~8Fk>aQ z&LL&q%jC2|p&St+86L;2L`IxNsbZbS%jPNPnQ-AO4v!;XIwh>@b_3b86}3|tFgAMv zhrfxS1L*kDVgjwF5Ie`ey|anFz4+G7cckfh7&Mi9aaudQPIi=Cg1!ga_>Z_ZvXuYcV8$#HJ@MZ3M1P5{mJpg0nd<)z|Q%>kMm0Ur~GIjvxZ8+^9Ho>0r z>r5pgRK)5AQ*Ag$P*?(&2U!C8$@9wmTh?zlawb+Y* zRh534KeexE3zWXASpTloHY$!40sa% zsr>X&&Cmt0^O@mb*!1Wu*I6jcbk3BfwSr#eB_D2H&3`o9Jn6?H0yqhZYaRYe(V)KQ zER>kW1V0N)^y7St{qm!v_QPg#1#LI`A@t!}t@i?~$duAMq=`3=H2O7}9-yz}{y~Hw G7XKG None: table = self.query_one("#update-table", DataTable) + table.cursor_type = "row" table.add_columns("Type", "Target", "Update") for i, update in enumerate(self.pending_updates): @@ -117,8 +125,72 @@ class ConfirmationApp(App): ) 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 + if self.pending_updates: + self.handle_row_highlight(0) + self.query_one("#btn-accept", Button).focus() + + if self.proposal_queue: + self.run_worker(self.poll_proposal_queue, thread=False) + + async def poll_proposal_queue(self) -> None: + """ + Background worker that polls the proposal queue for new extraction results. + """ + while True: + try: + result = await self.proposal_queue.get() + self.add_result(result) + except Exception as e: + # Log error but keep the worker running + self.log(f"Error polling proposal queue: {e}") + finally: + # Signal that the item has been processed + if hasattr(self.proposal_queue, "task_done"): + self.proposal_queue.task_done() + + def add_result(self, result: ExtractionResult) -> None: + """ + Adds results from the LLM processor to the TUI table. + """ + table = self.query_one("#update-table", DataTable) + start_index = len(self.pending_updates) + + for update in result.lore_updates + result.character_updates: + self.pending_updates.append(update) + actual_index = len(self.pending_updates) - 1 + + if isinstance(update, LoreUpdate): + table.add_row( + "Lore", + update.entity_name or "General", + update.content, + key=str(actual_index), + ) + 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(actual_index) + ) + + # If the table was previously empty and we added updates, focus the first one. + if start_index == 0 and self.pending_updates: + self.handle_row_highlight(0) + self.query_one("#btn-accept", Button).focus() + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self.handle_row_highlight(event.cursor_row) + + def handle_row_highlight(self, row: int) -> None: + self.selected_index = row + if self.selected_index < 0 or self.selected_index >= len(self.pending_updates): + return + update = self.pending_updates[self.selected_index] details_text = self.query_one("#details-text", Static) @@ -135,7 +207,7 @@ class ConfirmationApp(App): 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: + def on_button_pressed(self, event: Button.Pressed) -> None: if self.selected_index == -1: return @@ -237,5 +309,9 @@ class ConfirmationApp(App): ) 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") + if self.pending_updates: + self.handle_row_highlight(0) + self.query_one("#btn-accept", Button).focus() + else: + self.selected_index = -1 + self.query_one("#details-text", Static).update("All updates processed.")