Spaces:
Sleeping
Sleeping
fix: make stream_chat async to avoid event loop conflict with Gradio (#10)
Browse filesThe sync _run_sync wrapper created a new event loop inside Gradio's
existing async context, causing the chat to hang. Now stream_chat is
a native async generator that directly consumes _run_pipeline.
Co-authored-by: overthelex <mcvovkes@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- app.py +36 -70
- tests/test_app.py +6 -0
app.py
CHANGED
|
@@ -1,13 +1,11 @@
|
|
| 1 |
"""LMAF (Legal Multi-Agent Framework) Gradio chat app.
|
| 2 |
|
| 3 |
Chat interface for multi-agent legal consultation pipeline.
|
| 4 |
-
|
| 5 |
-
On HF Space: proxies to prod via gr.load().
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
import asyncio
|
| 11 |
import os
|
| 12 |
import sys
|
| 13 |
from pathlib import Path
|
|
@@ -37,105 +35,73 @@ async def _run_pipeline(question: str):
|
|
| 37 |
"""Run the multi-agent pipeline and yield status updates."""
|
| 38 |
from lmaf.core.config import Config
|
| 39 |
from lmaf.engine import LMAF
|
|
|
|
| 40 |
|
| 41 |
config = Config.from_env()
|
| 42 |
-
|
| 43 |
|
| 44 |
yield "Surveyor: аналізую правовий ландшафт..."
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
if
|
| 48 |
-
yield f"**Огляд**: {
|
| 49 |
|
| 50 |
yield "Planner: розробляю стратегію дослідження..."
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
if
|
| 54 |
-
yield f"**Стратегія**: {
|
| 55 |
|
| 56 |
for iteration in range(1, config.max_iterations + 1):
|
| 57 |
-
|
| 58 |
|
| 59 |
yield f"Ітерація {iteration}: Orchestrator вирішує наступний крок..."
|
| 60 |
-
orch_result = await
|
| 61 |
|
| 62 |
if not orch_result.success:
|
| 63 |
-
if
|
| 64 |
yield "Занадто багато послідовних помилок, зупиняюсь."
|
| 65 |
break
|
| 66 |
continue
|
| 67 |
|
| 68 |
-
yield
|
| 69 |
-
agent_result = await
|
| 70 |
-
|
| 71 |
)
|
| 72 |
if agent_result.success:
|
| 73 |
yield f"**Знайдено**: {agent_result.summary[:200]}"
|
| 74 |
|
| 75 |
yield "Reviewer: верифікую докази та посилання..."
|
| 76 |
-
|
| 77 |
|
| 78 |
if iteration % config.critic_every_n == 0:
|
| 79 |
yield "Critic: аудит стратегії та повноти аналізу..."
|
| 80 |
-
critic_result = await
|
| 81 |
-
|
| 82 |
|
| 83 |
-
for critique in
|
| 84 |
if critique.type == "strategy":
|
| 85 |
-
await
|
| 86 |
-
|
| 87 |
)
|
| 88 |
-
from lmaf.state.research_state import CritiqueStatus
|
| 89 |
critique.status = CritiqueStatus.RESOLVED
|
| 90 |
|
| 91 |
if "можна завершувати: True" in critic_result.summary:
|
| 92 |
yield "Critic схвалив -- формую консультацію."
|
| 93 |
break
|
| 94 |
|
| 95 |
-
if not
|
| 96 |
yield "Всі питання вирішено -- формую консультацію."
|
| 97 |
break
|
| 98 |
|
| 99 |
yield "Formatter: оформлюю фінальну консультацію..."
|
| 100 |
-
await
|
| 101 |
|
| 102 |
-
yield
|
| 103 |
|
| 104 |
|
| 105 |
-
def
|
| 106 |
-
"""
|
| 107 |
-
if not message.strip():
|
| 108 |
-
return "Будь ласка, опишіть вашу правову ситуацію."
|
| 109 |
-
|
| 110 |
-
if not has_api_keys():
|
| 111 |
-
return (
|
| 112 |
-
"API ключі не налаштовано. Цей інстанс працює в демо-режимі.\n\n"
|
| 113 |
-
"Для реальних консультацій використовуйте "
|
| 114 |
-
"[agents.legal.org.ua](https://agents.legal.org.ua)."
|
| 115 |
-
)
|
| 116 |
-
|
| 117 |
-
final = ""
|
| 118 |
-
for update in _run_sync(message):
|
| 119 |
-
final = update
|
| 120 |
-
return final
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def _run_sync(question: str):
|
| 124 |
-
"""Run async generator synchronously, collecting results."""
|
| 125 |
-
loop = asyncio.new_event_loop()
|
| 126 |
-
gen = _run_pipeline(question)
|
| 127 |
-
try:
|
| 128 |
-
while True:
|
| 129 |
-
result = loop.run_until_complete(gen.__anext__())
|
| 130 |
-
yield result
|
| 131 |
-
except StopAsyncIteration:
|
| 132 |
-
pass
|
| 133 |
-
finally:
|
| 134 |
-
loop.close()
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
def stream_chat(message: str, history: list[dict]):
|
| 138 |
-
"""Streaming chat handler -- yields incremental updates."""
|
| 139 |
if not message.strip():
|
| 140 |
yield "Будь ласка, опишіть вашу правову ситуацію."
|
| 141 |
return
|
|
@@ -149,17 +115,17 @@ def stream_chat(message: str, history: list[dict]):
|
|
| 149 |
return
|
| 150 |
|
| 151 |
accumulated = ""
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
if update.startswith("**") or update.startswith("#"):
|
| 154 |
accumulated += f"\n\n{update}"
|
| 155 |
-
elif
|
| 156 |
-
and not update.startswith("Ітерація") and not update.startswith("Researcher") \
|
| 157 |
-
and not update.startswith("Reviewer") and not update.startswith("Critic") \
|
| 158 |
-
and not update.startswith("Formatter") and not update.startswith("Всі") \
|
| 159 |
-
and not update.startswith("Занадто"):
|
| 160 |
-
accumulated = update
|
| 161 |
-
else:
|
| 162 |
accumulated += f"\n\n__{update}__"
|
|
|
|
|
|
|
| 163 |
yield accumulated
|
| 164 |
|
| 165 |
|
|
|
|
| 1 |
"""LMAF (Legal Multi-Agent Framework) Gradio chat app.
|
| 2 |
|
| 3 |
Chat interface for multi-agent legal consultation pipeline.
|
| 4 |
+
Runs on prod (agents.legal.org.ua) and HuggingFace Space.
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
import os
|
| 10 |
import sys
|
| 11 |
from pathlib import Path
|
|
|
|
| 35 |
"""Run the multi-agent pipeline and yield status updates."""
|
| 36 |
from lmaf.core.config import Config
|
| 37 |
from lmaf.engine import LMAF
|
| 38 |
+
from lmaf.state.research_state import CritiqueStatus
|
| 39 |
|
| 40 |
config = Config.from_env()
|
| 41 |
+
lmaf = LMAF(question, config)
|
| 42 |
|
| 43 |
yield "Surveyor: аналізую правовий ландшафт..."
|
| 44 |
+
await lmaf.surveyor.run(lmaf.state)
|
| 45 |
+
lmaf._loop.survey_done = True
|
| 46 |
+
if lmaf.state.survey_summary:
|
| 47 |
+
yield f"**Огляд**: {lmaf.state.survey_summary[:300]}"
|
| 48 |
|
| 49 |
yield "Planner: розробляю стратегію дослідження..."
|
| 50 |
+
await lmaf.planner.run(lmaf.state)
|
| 51 |
+
lmaf._loop.plan_done = True
|
| 52 |
+
if lmaf.state.strategy.approach:
|
| 53 |
+
yield f"**Стратегія**: {lmaf.state.strategy.approach[:300]}"
|
| 54 |
|
| 55 |
for iteration in range(1, config.max_iterations + 1):
|
| 56 |
+
lmaf.state.iteration = iteration
|
| 57 |
|
| 58 |
yield f"Ітерація {iteration}: Orchestrator вирішує наступний крок..."
|
| 59 |
+
orch_result = await lmaf.orchestrator.run(lmaf.state)
|
| 60 |
|
| 61 |
if not orch_result.success:
|
| 62 |
+
if lmaf._loop.consecutive_failures >= config.max_consecutive_failures:
|
| 63 |
yield "Занадто багато послідовних помилок, зупиняюсь."
|
| 64 |
break
|
| 65 |
continue
|
| 66 |
|
| 67 |
+
yield "Researcher: шукаю судову практику та законодавство..."
|
| 68 |
+
agent_result = await lmaf.researcher.run(
|
| 69 |
+
lmaf.state, task_description=orch_result.summary
|
| 70 |
)
|
| 71 |
if agent_result.success:
|
| 72 |
yield f"**Знайдено**: {agent_result.summary[:200]}"
|
| 73 |
|
| 74 |
yield "Reviewer: верифікую докази та посилання..."
|
| 75 |
+
await lmaf.reviewer.run(lmaf.state)
|
| 76 |
|
| 77 |
if iteration % config.critic_every_n == 0:
|
| 78 |
yield "Critic: аудит стратегії та повноти аналізу..."
|
| 79 |
+
critic_result = await lmaf.critic.run(lmaf.state)
|
| 80 |
+
lmaf._loop.last_critic_iteration = iteration
|
| 81 |
|
| 82 |
+
for critique in lmaf.state.active_critiques():
|
| 83 |
if critique.type == "strategy":
|
| 84 |
+
await lmaf.planner.run(
|
| 85 |
+
lmaf.state, revision_critique=critique.details
|
| 86 |
)
|
|
|
|
| 87 |
critique.status = CritiqueStatus.RESOLVED
|
| 88 |
|
| 89 |
if "можна завершувати: True" in critic_result.summary:
|
| 90 |
yield "Critic схвалив -- формую консультацію."
|
| 91 |
break
|
| 92 |
|
| 93 |
+
if not lmaf.state.open_questions() and not lmaf.state.active_critiques():
|
| 94 |
yield "Всі питання вирішено -- формую консультацію."
|
| 95 |
break
|
| 96 |
|
| 97 |
yield "Formatter: оформлюю фінальну консультацію..."
|
| 98 |
+
await lmaf.formatter.run(lmaf.state)
|
| 99 |
|
| 100 |
+
yield lmaf.state.answer or "Не вдалося сформувати відповідь."
|
| 101 |
|
| 102 |
|
| 103 |
+
async def stream_chat(message: str, history: list[dict]):
|
| 104 |
+
"""Async streaming chat handler -- yields incremental updates."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
if not message.strip():
|
| 106 |
yield "Будь ласка, опишіть вашу правову ситуацію."
|
| 107 |
return
|
|
|
|
| 115 |
return
|
| 116 |
|
| 117 |
accumulated = ""
|
| 118 |
+
status_prefixes = (
|
| 119 |
+
"Surveyor", "Planner", "Ітерація", "Researcher",
|
| 120 |
+
"Reviewer", "Critic", "Formatter", "Всі", "Занадто",
|
| 121 |
+
)
|
| 122 |
+
async for update in _run_pipeline(message):
|
| 123 |
if update.startswith("**") or update.startswith("#"):
|
| 124 |
accumulated += f"\n\n{update}"
|
| 125 |
+
elif update.startswith(status_prefixes):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
accumulated += f"\n\n__{update}__"
|
| 127 |
+
else:
|
| 128 |
+
accumulated = update
|
| 129 |
yield accumulated
|
| 130 |
|
| 131 |
|
tests/test_app.py
CHANGED
|
@@ -33,3 +33,9 @@ def test_app_has_iframe_srcdoc():
|
|
| 33 |
import app
|
| 34 |
assert "iframe" in app.ARCHITECTURE_HTML
|
| 35 |
assert "srcdoc" in app.ARCHITECTURE_HTML
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
import app
|
| 34 |
assert "iframe" in app.ARCHITECTURE_HTML
|
| 35 |
assert "srcdoc" in app.ARCHITECTURE_HTML
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_stream_chat_is_async_generator():
|
| 39 |
+
import app
|
| 40 |
+
import inspect
|
| 41 |
+
assert inspect.isasyncgenfunction(app.stream_chat)
|