Compare commits
3 Commits
v0.12.3-be
...
v0.12.5-be
| Author | SHA1 | Date | |
|---|---|---|---|
| bc0cbb0f7b | |||
| 6740ed2177 | |||
| 8cc90bd24d |
@@ -14,6 +14,11 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from app.application.chunking import chunk_text, split_in_half
|
from app.application.chunking import chunk_text, split_in_half
|
||||||
|
from app.application.import_status import (
|
||||||
|
notify_status,
|
||||||
|
reset_status_queue,
|
||||||
|
set_status_queue,
|
||||||
|
)
|
||||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||||
from app.application.llm_retry import generate_with_retry
|
from app.application.llm_retry import generate_with_retry
|
||||||
from app.application.streaming import with_heartbeat
|
from app.application.streaming import with_heartbeat
|
||||||
@@ -510,86 +515,101 @@ class ImportCampaignUseCase:
|
|||||||
skipped = 0
|
skipped = 0
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
done_count = 0
|
done_count = 0
|
||||||
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||||
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||||
# vague par vague, dans l'ordre du livre.
|
status_queue: asyncio.Queue = asyncio.Queue()
|
||||||
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
|
status_token = set_status_queue(status_queue)
|
||||||
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
|
try:
|
||||||
# si AUCUN morceau ne passe (cf. après la boucle).
|
# PARALLÉLISME : les morceaux sont traités par VAGUES de `map_concurrency`
|
||||||
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
|
# appels simultanés. L'ordre narratif est préservé : la fusion se fait
|
||||||
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
|
# vague par vague, dans l'ordre du livre.
|
||||||
for start in range(0, total, self._map_concurrency):
|
# RÉSILIENCE : un morceau qui échoue (provider saturé, quota, etc.) est
|
||||||
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
|
# SAUTÉ — on ne perd pas tout l'import pour autant. On n'abandonne que
|
||||||
gathered = asyncio.gather(
|
# si AUCUN morceau ne passe (cf. après la boucle).
|
||||||
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
|
# HEARTBEAT : keep-alive pendant la vague d'appels LLM pour ne jamais
|
||||||
for i, c in wave),
|
# laisser le flux SSE silencieux (sinon le Core coupe sur inactivité).
|
||||||
return_exceptions=True,
|
for start in range(0, total, self._map_concurrency):
|
||||||
)
|
wave = list(enumerate(chunks))[start:start + self._map_concurrency]
|
||||||
results: list | None = None
|
gathered = asyncio.gather(
|
||||||
async for kind, payload in with_heartbeat(gathered):
|
*(self._map_chunk(c, index=i, total=total, toc_block=toc_block)
|
||||||
if kind == "heartbeat":
|
for i, c in wave),
|
||||||
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
return_exceptions=True,
|
||||||
else:
|
)
|
||||||
results = payload
|
results: list | None = None
|
||||||
for (i, _), res in zip(wave, results or []):
|
async for kind, payload in with_heartbeat(gathered, status_queue=status_queue):
|
||||||
done_count += 1
|
if kind == "heartbeat":
|
||||||
if isinstance(res, LLMProviderError):
|
yield {"type": "heartbeat", "current": done_count + 1, "total": total}
|
||||||
skipped += 1
|
elif kind == "status":
|
||||||
last_error = str(res)
|
yield {"type": "status", "message": payload,
|
||||||
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
|
"current": done_count + 1, "total": total}
|
||||||
yield {"type": "chunk_failed", "current": i + 1, "total": total,
|
else:
|
||||||
"message": str(res)[:300]}
|
results = payload
|
||||||
elif isinstance(res, BaseException):
|
for (i, _), res in zip(wave, results or []):
|
||||||
raise res # bug inattendu : ne pas l'avaler en silence
|
done_count += 1
|
||||||
else:
|
if isinstance(res, LLMProviderError):
|
||||||
merger.add((res or {}).get("arcs") or [])
|
skipped += 1
|
||||||
merger.add_npcs((res or {}).get("npcs") or [])
|
last_error = str(res)
|
||||||
arcs, chapters, scenes = merger.counts()
|
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, res)
|
||||||
yield {
|
yield {"type": "chunk_failed", "current": i + 1, "total": total,
|
||||||
"type": "progress",
|
"message": str(res)[:300]}
|
||||||
"current": done_count,
|
elif isinstance(res, BaseException):
|
||||||
"total": total,
|
raise res # bug inattendu : ne pas l'avaler en silence
|
||||||
"arc_count": arcs,
|
else:
|
||||||
"chapter_count": chapters,
|
merger.add((res or {}).get("arcs") or [])
|
||||||
"scene_count": scenes,
|
merger.add_npcs((res or {}).get("npcs") or [])
|
||||||
"npc_count": len(merger.npcs()),
|
arcs, chapters, scenes = merger.counts()
|
||||||
"skipped": skipped,
|
yield {
|
||||||
}
|
"type": "progress",
|
||||||
|
"current": done_count,
|
||||||
|
"total": total,
|
||||||
|
"arc_count": arcs,
|
||||||
|
"chapter_count": chapters,
|
||||||
|
"scene_count": scenes,
|
||||||
|
"npc_count": len(merger.npcs()),
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
|
||||||
if total > 0 and skipped == total:
|
if total > 0 and skipped == total:
|
||||||
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
|
# Tout a échoué : "done" vide serait trompeur → erreur explicite.
|
||||||
yield {"type": "error",
|
yield {"type": "error",
|
||||||
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
|
"message": "Tous les morceaux ont échoué auprès du fournisseur IA. "
|
||||||
f"Dernier message : {last_error or 'inconnu'}"}
|
f"Dernier message : {last_error or 'inconnu'}"}
|
||||||
return
|
return
|
||||||
|
|
||||||
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
|
if total > 0 and merger.counts()[0] == 0 and not merger.npcs():
|
||||||
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
|
# Le texte a été extrait mais le modèle n'a produit AUCUNE structure
|
||||||
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
|
# exploitable : sans ce signal, l'UI reçoit un `done` vide et
|
||||||
# l'utilisateur conclut à tort que le PDF est illisible.
|
# l'utilisateur conclut à tort que le PDF est illisible.
|
||||||
yield {"type": "error",
|
yield {"type": "error",
|
||||||
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
|
"message": "Le texte du PDF a été extrait, mais le modèle n'a produit "
|
||||||
"aucune structure exploitable (réponses JSON vides ou coupées). "
|
"aucune structure exploitable (réponses JSON vides ou coupées). "
|
||||||
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
|
"Réduisez la taille des morceaux d'import, augmentez la fenêtre "
|
||||||
"de contexte (num_ctx) ou essayez un autre modèle."}
|
"de contexte (num_ctx) ou essayez un autre modèle."}
|
||||||
return
|
return
|
||||||
|
|
||||||
# Consolidation finale : fusion des quasi-doublons inter-morceaux
|
# Consolidation finale : fusion des quasi-doublons inter-morceaux
|
||||||
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
# (best-effort, voir _consolidate). Inutile sur un import mono-morceau.
|
||||||
if total > 1:
|
if total > 1:
|
||||||
yield {"type": "consolidating", "total": total}
|
yield {"type": "consolidating", "total": total}
|
||||||
async for kind, _ in with_heartbeat(self._consolidate(merger)):
|
async for kind, payload in with_heartbeat(
|
||||||
if kind == "heartbeat":
|
self._consolidate(merger), status_queue=status_queue
|
||||||
yield {"type": "heartbeat", "current": total, "total": total}
|
):
|
||||||
|
if kind == "heartbeat":
|
||||||
|
yield {"type": "heartbeat", "current": total, "total": total}
|
||||||
|
elif kind == "status":
|
||||||
|
yield {"type": "status", "message": payload,
|
||||||
|
"current": total, "total": total}
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
"type": "done",
|
"type": "done",
|
||||||
"arcs": _serialize_arcs(merger.result()),
|
"arcs": _serialize_arcs(merger.result()),
|
||||||
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
|
"npcs": [{"name": n.name, "description": n.description} for n in merger.npcs()],
|
||||||
"page_count": doc.page_count,
|
"page_count": doc.page_count,
|
||||||
"ocr_page_count": doc.ocr_page_count,
|
"ocr_page_count": doc.ocr_page_count,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
reset_status_queue(status_token)
|
||||||
|
|
||||||
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
|
# --- Consolidation finale (fusion des quasi-doublons) ---------------------
|
||||||
|
|
||||||
@@ -666,6 +686,9 @@ class ImportCampaignUseCase:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||||
index, depth + 1)
|
index, depth + 1)
|
||||||
|
notify_status(
|
||||||
|
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||||
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
a = await self._extract_payload(
|
a = await self._extract_payload(
|
||||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||||
b = await self._extract_payload(
|
b = await self._extract_payload(
|
||||||
@@ -679,6 +702,9 @@ class ImportCampaignUseCase:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||||
index, depth + 1)
|
index, depth + 1)
|
||||||
|
notify_status(
|
||||||
|
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||||
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
a = await self._extract_payload(
|
a = await self._extract_payload(
|
||||||
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
left, index=index, total=total, depth=depth + 1, toc_block=toc_block)
|
||||||
b = await self._extract_payload(
|
b = await self._extract_payload(
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
|
from app.application.chunking import CHUNK_TARGET_TOKENS, chunk_text, split_in_half
|
||||||
|
from app.application.import_status import (
|
||||||
|
notify_status,
|
||||||
|
reset_status_queue,
|
||||||
|
set_status_queue,
|
||||||
|
)
|
||||||
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
from app.application.llm_json import load_json_object, looks_like_truncated_json
|
||||||
from app.application.llm_retry import generate_with_retry
|
from app.application.llm_retry import generate_with_retry
|
||||||
from app.application.streaming import with_heartbeat
|
from app.application.streaming import with_heartbeat
|
||||||
@@ -332,35 +339,46 @@ class ImportRulesUseCase:
|
|||||||
merger = _SectionMerger()
|
merger = _SectionMerger()
|
||||||
skipped = 0
|
skipped = 0
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
for i, chunk in enumerate(chunks):
|
# Canal de statut : les couches profondes (retry LLM, re-découpage) y
|
||||||
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
# publient des messages destinés à l'UI — cf. import_status.notify_status.
|
||||||
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
status_queue: asyncio.Queue = asyncio.Queue()
|
||||||
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
|
status_token = set_status_queue(status_queue)
|
||||||
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
|
try:
|
||||||
new_titles: list[str] = []
|
for i, chunk in enumerate(chunks):
|
||||||
try:
|
# RÉSILIENCE : un morceau qui échoue est SAUTÉ, l'import continue.
|
||||||
sections: dict[str, str] | None = None
|
# Abandon seulement si AUCUN morceau ne passe (cf. après la boucle).
|
||||||
async for kind, payload in with_heartbeat(
|
# HEARTBEAT : on émet des keep-alive pendant l'appel LLM (long sur un
|
||||||
self._map_chunk(chunk, index=i, total=total)
|
# provider lent) pour que le flux SSE ne soit jamais coupé par le Core.
|
||||||
):
|
new_titles: list[str] = []
|
||||||
if kind == "heartbeat":
|
try:
|
||||||
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
sections: dict[str, str] | None = None
|
||||||
else:
|
async for kind, payload in with_heartbeat(
|
||||||
sections = payload
|
self._map_chunk(chunk, index=i, total=total),
|
||||||
new_titles = merger.add(sections or {})
|
status_queue=status_queue,
|
||||||
except LLMProviderError as exc:
|
):
|
||||||
skipped += 1
|
if kind == "heartbeat":
|
||||||
last_error = str(exc)
|
yield {"type": "heartbeat", "current": i + 1, "total": total}
|
||||||
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
|
elif kind == "status":
|
||||||
yield {"type": "chunk_failed", "current": i + 1, "total": total,
|
yield {"type": "status", "message": payload,
|
||||||
"message": str(exc)[:300]}
|
"current": i + 1, "total": total}
|
||||||
yield {
|
else:
|
||||||
"type": "progress",
|
sections = payload
|
||||||
"current": i + 1,
|
new_titles = merger.add(sections or {})
|
||||||
"total": total,
|
except LLMProviderError as exc:
|
||||||
"new_sections": new_titles,
|
skipped += 1
|
||||||
"skipped": skipped,
|
last_error = str(exc)
|
||||||
}
|
logger.warning("Morceau %s/%s ignoré (échec LLM) : %s", i + 1, total, exc)
|
||||||
|
yield {"type": "chunk_failed", "current": i + 1, "total": total,
|
||||||
|
"message": str(exc)[:300]}
|
||||||
|
yield {
|
||||||
|
"type": "progress",
|
||||||
|
"current": i + 1,
|
||||||
|
"total": total,
|
||||||
|
"new_sections": new_titles,
|
||||||
|
"skipped": skipped,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
reset_status_queue(status_token)
|
||||||
|
|
||||||
if total > 0 and skipped == total:
|
if total > 0 and skipped == total:
|
||||||
yield {"type": "error",
|
yield {"type": "error",
|
||||||
@@ -423,6 +441,9 @@ class ImportRulesUseCase:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
"Morceau %s : timeout de génération → re-découpage en 2 moitiés (niveau %s).",
|
||||||
index, depth + 1)
|
index, depth + 1)
|
||||||
|
notify_status(
|
||||||
|
f"Le modèle est trop lent sur le morceau {index + 1} : "
|
||||||
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||||
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||||
return _combine_sections(a, b)
|
return _combine_sections(a, b)
|
||||||
@@ -437,6 +458,9 @@ class ImportRulesUseCase:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
"Morceau %s : sortie tronquée → re-découpage en 2 moitiés (niveau %s).",
|
||||||
index, depth + 1)
|
index, depth + 1)
|
||||||
|
notify_status(
|
||||||
|
f"Réponse du modèle coupée sur le morceau {index + 1} : "
|
||||||
|
"re-découpage en 2 moitiés plus digestes…")
|
||||||
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
a = await self._extract_sections(left, index=index, total=total, depth=depth + 1)
|
||||||
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
b = await self._extract_sections(right, index=index, total=total, depth=depth + 1)
|
||||||
return _combine_sections(a, b)
|
return _combine_sections(a, b)
|
||||||
|
|||||||
39
brain/app/application/import_status.py
Normal file
39
brain/app/application/import_status.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Canal de statut des imports : remonte à l'UI ce qui n'existait qu'en logs.
|
||||||
|
|
||||||
|
Problème résolu : pendant un import, les événements internes (retry parce que
|
||||||
|
le fournisseur IA est saturé, re-découpage d'un morceau trop gros…) n'étaient
|
||||||
|
visibles que dans les logs Docker. L'utilisateur voyait une barre de
|
||||||
|
progression figée sans explication.
|
||||||
|
|
||||||
|
Mécanisme : le flux d'import (use case `stream()`) installe une Queue dans une
|
||||||
|
ContextVar ; les couches profondes (retry LLM, re-découpage) y publient des
|
||||||
|
messages via `notify_status()` sans connaître le flux SSE. La ContextVar est
|
||||||
|
propagée automatiquement aux tâches asyncio enfants → chaque import concurrent
|
||||||
|
a SA queue, sans couplage ni paramètre à faire transiter partout.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextvars import ContextVar, Token
|
||||||
|
|
||||||
|
_QUEUE: ContextVar[asyncio.Queue | None] = ContextVar("import_status_queue", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def set_status_queue(queue: asyncio.Queue | None) -> Token:
|
||||||
|
"""Installe la queue de statut pour le contexte courant (et ses tâches filles).
|
||||||
|
|
||||||
|
Renvoie le token à passer à `reset_status_queue` en fin d'import.
|
||||||
|
"""
|
||||||
|
return _QUEUE.set(queue)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_status_queue(token: Token) -> None:
|
||||||
|
_QUEUE.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_status(message: str) -> None:
|
||||||
|
"""Publie un message de statut si un import écoute. No-op sinon (appels
|
||||||
|
LLM hors import : chat, génération de page…)."""
|
||||||
|
queue = _QUEUE.get()
|
||||||
|
if queue is not None:
|
||||||
|
queue.put_nowait(message)
|
||||||
@@ -14,6 +14,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from app.application.import_status import notify_status
|
||||||
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
|
from app.domain.ports import LLMGenerationTimeout, LLMProvider, LLMProviderError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -103,6 +104,14 @@ async def generate_with_retry(
|
|||||||
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
|
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
|
||||||
exc, wait,
|
exc, wait,
|
||||||
)
|
)
|
||||||
|
# Remonte aussi l'info à l'UI (flux d'import) : sans ça l'utilisateur
|
||||||
|
# voit une barre figée sans savoir que le fournisseur est saturé.
|
||||||
|
notify_status(
|
||||||
|
("Fournisseur IA saturé (rate limit)" if _is_rate_limit(exc)
|
||||||
|
else "Appel IA échoué")
|
||||||
|
+ f" — tentative {attempt + 1}/{_ATTEMPTS}, nouvel essai dans {int(wait)}s. "
|
||||||
|
+ str(exc)[:160]
|
||||||
|
)
|
||||||
await asyncio.sleep(wait)
|
await asyncio.sleep(wait)
|
||||||
assert last_error is not None
|
assert last_error is not None
|
||||||
raise last_error
|
raise last_error
|
||||||
|
|||||||
@@ -25,21 +25,43 @@ async def with_heartbeat(
|
|||||||
coro: Awaitable[Any],
|
coro: Awaitable[Any],
|
||||||
*,
|
*,
|
||||||
interval: float = HEARTBEAT_INTERVAL_SECONDS,
|
interval: float = HEARTBEAT_INTERVAL_SECONDS,
|
||||||
|
status_queue: "asyncio.Queue | None" = None,
|
||||||
) -> AsyncIterator[tuple[str, Any]]:
|
) -> AsyncIterator[tuple[str, Any]]:
|
||||||
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
|
"""Exécute `coro` en émettant ('heartbeat', None) toutes les `interval`s tant
|
||||||
qu'elle n'est pas terminée, puis ('result', valeur).
|
qu'elle n'est pas terminée, puis ('result', valeur).
|
||||||
|
|
||||||
|
Si `status_queue` est fournie, les messages qui y sont publiés pendant
|
||||||
|
l'exécution (cf. import_status.notify_status : retry LLM, re-découpage…)
|
||||||
|
sont émis AU FIL DE L'EAU sous forme ('status', message) — c'est ce qui
|
||||||
|
permet à l'UI d'expliquer une attente au lieu d'une barre figée.
|
||||||
|
|
||||||
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
|
L'exception éventuelle de `coro` est propagée (re-levée par `task.result()`),
|
||||||
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
|
donc l'appelant peut l'attraper normalement. Si l'itération est abandonnée
|
||||||
(client déconnecté), la tâche sous-jacente est annulée.
|
(client déconnecté), la tâche sous-jacente est annulée.
|
||||||
"""
|
"""
|
||||||
task: asyncio.Task = asyncio.ensure_future(coro)
|
task: asyncio.Task = asyncio.ensure_future(coro)
|
||||||
|
getter: asyncio.Task | None = None
|
||||||
try:
|
try:
|
||||||
while not task.done():
|
while not task.done():
|
||||||
done, _ = await asyncio.wait({task}, timeout=interval)
|
waiters: set[asyncio.Task] = {task}
|
||||||
|
if status_queue is not None and getter is None:
|
||||||
|
getter = asyncio.ensure_future(status_queue.get())
|
||||||
|
if getter is not None:
|
||||||
|
waiters.add(getter)
|
||||||
|
done, _ = await asyncio.wait(
|
||||||
|
waiters, timeout=interval, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
if getter is not None and getter in done:
|
||||||
|
yield ("status", getter.result())
|
||||||
|
getter = None # un nouveau get() sera créé au tour suivant
|
||||||
if not done:
|
if not done:
|
||||||
yield ("heartbeat", None)
|
yield ("heartbeat", None)
|
||||||
|
# Vide les statuts restés en file (publiés juste avant la fin de la tâche).
|
||||||
|
if status_queue is not None:
|
||||||
|
while not status_queue.empty():
|
||||||
|
yield ("status", status_queue.get_nowait())
|
||||||
yield ("result", task.result())
|
yield ("result", task.result())
|
||||||
finally:
|
finally:
|
||||||
|
if getter is not None and not getter.done():
|
||||||
|
getter.cancel()
|
||||||
if not task.done():
|
if not task.done():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
|
|||||||
@@ -144,6 +144,16 @@ class GeminiLLMProvider:
|
|||||||
) as response:
|
) as response:
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
detail = (await response.aread()).decode("utf-8", "replace").strip()
|
||||||
|
# 401/403 = clé rejetée par GOOGLE (pas un problème LoreMind) :
|
||||||
|
# message actionnable plutôt que le JSON brut de l'API.
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
raise LLMProviderError(
|
||||||
|
"Erreur Gemini : clé API refusée par Google "
|
||||||
|
f"(HTTP {response.status_code}). Vérifiez que la clé vient bien "
|
||||||
|
"de aistudio.google.com (« Get API key ») et qu'elle n'a pas de "
|
||||||
|
"restrictions (API ou adresse IP) dans la Google Cloud Console. "
|
||||||
|
f"Détail : {detail[:300]}"
|
||||||
|
)
|
||||||
raise LLMProviderError(
|
raise LLMProviderError(
|
||||||
f"Erreur Gemini (HTTP {response.status_code})"
|
f"Erreur Gemini (HTTP {response.status_code})"
|
||||||
+ (f" : {detail[:500]}" if detail else "")
|
+ (f" : {detail[:500]}" if detail else "")
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.12.3-beta",
|
version="0.12.5-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.12.3-beta</version>
|
<version>0.12.5-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,11 @@ public class CampaignImportService {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
campaignPdfImporter.importCampaignStreaming(
|
campaignPdfImporter.importCampaignStreaming(
|
||||||
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
|
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ public class GameSystemService {
|
|||||||
String filename,
|
String filename,
|
||||||
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
|
java.util.function.Consumer<com.loremind.domain.gamesystemcontext.RulesImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
java.util.function.Consumer<String> onStatus,
|
||||||
java.util.function.Consumer<RulesImportResult> onDone,
|
java.util.function.Consumer<RulesImportResult> onDone,
|
||||||
java.util.function.Consumer<Throwable> onError) {
|
java.util.function.Consumer<Throwable> onError) {
|
||||||
rulesPdfImporter.importRulesStreaming(
|
rulesPdfImporter.importRulesStreaming(
|
||||||
pdfBytes, filename, onProgress, onHeartbeat, onDone, onError);
|
pdfBytes, filename, onProgress, onHeartbeat, onStatus, onDone, onError);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ public interface CampaignPdfImporter {
|
|||||||
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||||
* avancée à afficher, mais le canal SSE vers le navigateur
|
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||||
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
||||||
|
* @param onStatus invoqué avec un message lisible quand quelque chose se
|
||||||
|
* passe pendant l'attente (fournisseur saturé → retry,
|
||||||
|
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
|
||||||
|
* pour que l'utilisateur n'ait pas à lire les logs.
|
||||||
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
|
* @param onDone invoqué une fois avec l'arbre proposé (non persisté).
|
||||||
* @param onError invoqué si l'extraction/structuration échoue.
|
* @param onError invoqué si l'extraction/structuration échoue.
|
||||||
*/
|
*/
|
||||||
@@ -27,6 +31,7 @@ public interface CampaignPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError);
|
Consumer<Throwable> onError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ public interface RulesPdfImporter {
|
|||||||
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
* @param onHeartbeat invoqué périodiquement pendant un appel LLM long (aucune
|
||||||
* avancée à afficher, mais le canal SSE vers le navigateur
|
* avancée à afficher, mais le canal SSE vers le navigateur
|
||||||
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
* doit rester actif — sinon un proxy intermédiaire le coupe).
|
||||||
|
* @param onStatus invoqué avec un message lisible quand quelque chose se
|
||||||
|
* passe pendant l'attente (fournisseur saturé → retry,
|
||||||
|
* morceau re-découpé, morceau ignoré…) — affiché par l'UI
|
||||||
|
* pour que l'utilisateur n'ait pas à lire les logs.
|
||||||
* @param onDone invoqué une fois avec le résultat final.
|
* @param onDone invoqué une fois avec le résultat final.
|
||||||
* @param onError invoqué si l'extraction/structuration échoue.
|
* @param onError invoqué si l'extraction/structuration échoue.
|
||||||
*/
|
*/
|
||||||
@@ -38,6 +42,7 @@ public interface RulesPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError);
|
Consumer<Throwable> onError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||||
.doOnNext(sse -> handleEvent(
|
.doOnNext(sse -> handleEvent(
|
||||||
sse, pageCount, ocrPageCount, terminated,
|
sse, pageCount, ocrPageCount, terminated,
|
||||||
onProgress, onHeartbeat, onDone, onError))
|
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||||
.blockLast();
|
.blockLast();
|
||||||
if (!terminated[0]) {
|
if (!terminated[0]) {
|
||||||
onError.accept(new CampaignImportException(
|
onError.accept(new CampaignImportException(
|
||||||
@@ -110,6 +111,7 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<CampaignImportProgress> onProgress,
|
Consumer<CampaignImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<CampaignImportProposal> onDone,
|
Consumer<CampaignImportProposal> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -122,6 +124,22 @@ public class BrainCampaignImportClient implements CampaignPdfImporter {
|
|||||||
onHeartbeat.run();
|
onHeartbeat.run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ("status".equals(event)) {
|
||||||
|
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||||
|
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||||
|
onStatus.accept(readMessage(data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("chunk_failed".equals(event)) {
|
||||||
|
JsonNode node = readJson(data);
|
||||||
|
String msg = node != null && node.hasNonNull("message")
|
||||||
|
? node.get("message").asText() : "";
|
||||||
|
int current = node != null ? node.path("current").asInt() : 0;
|
||||||
|
int total = node != null ? node.path("total").asInt() : 0;
|
||||||
|
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
|
||||||
|
+ (msg.isEmpty() ? "." : " : " + msg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ("error".equals(event)) {
|
if ("error".equals(event)) {
|
||||||
terminated[0] = true;
|
terminated[0] = true;
|
||||||
onError.accept(new CampaignImportException(
|
onError.accept(new CampaignImportException(
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
String filename,
|
String filename,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -141,7 +142,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
.timeout(Duration.ofSeconds(importTimeoutSeconds))
|
||||||
.doOnNext(sse -> handleEvent(
|
.doOnNext(sse -> handleEvent(
|
||||||
sse, pageCount, ocrPageCount, terminated,
|
sse, pageCount, ocrPageCount, terminated,
|
||||||
onProgress, onHeartbeat, onDone, onError))
|
onProgress, onHeartbeat, onStatus, onDone, onError))
|
||||||
.blockLast();
|
.blockLast();
|
||||||
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
|
// Flux terminé sans event done/error (ex: connexion coupée) → on signale.
|
||||||
if (!terminated[0]) {
|
if (!terminated[0]) {
|
||||||
@@ -168,6 +169,7 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
boolean[] terminated,
|
boolean[] terminated,
|
||||||
Consumer<RulesImportProgress> onProgress,
|
Consumer<RulesImportProgress> onProgress,
|
||||||
Runnable onHeartbeat,
|
Runnable onHeartbeat,
|
||||||
|
Consumer<String> onStatus,
|
||||||
Consumer<RulesImportResult> onDone,
|
Consumer<RulesImportResult> onDone,
|
||||||
Consumer<Throwable> onError) {
|
Consumer<Throwable> onError) {
|
||||||
|
|
||||||
@@ -181,6 +183,22 @@ public class BrainRulesImportClient implements RulesPdfImporter {
|
|||||||
onHeartbeat.run();
|
onHeartbeat.run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ("status".equals(event)) {
|
||||||
|
// Message d'attente lisible (retry sur fournisseur saturé, morceau
|
||||||
|
// re-découpé…) : affiché par l'UI au lieu de n'exister qu'en logs.
|
||||||
|
onStatus.accept(readMessage(data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("chunk_failed".equals(event)) {
|
||||||
|
JsonNode node = readJson(data);
|
||||||
|
String msg = node != null && node.hasNonNull("message")
|
||||||
|
? node.get("message").asText() : "";
|
||||||
|
int current = node != null ? node.path("current").asInt() : 0;
|
||||||
|
int total = node != null ? node.path("total").asInt() : 0;
|
||||||
|
onStatus.accept("Morceau " + current + "/" + total + " ignoré"
|
||||||
|
+ (msg.isEmpty() ? "." : " : " + msg));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ("error".equals(event)) {
|
if ("error".equals(event)) {
|
||||||
terminated[0] = true;
|
terminated[0] = true;
|
||||||
onError.accept(new RulesImportException(
|
onError.accept(new RulesImportException(
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ public class CampaignImportController {
|
|||||||
bytes, filename,
|
bytes, filename,
|
||||||
progress -> sendEvent(emitter, clientGone, "progress", progress),
|
progress -> sendEvent(emitter, clientGone, "progress", progress),
|
||||||
() -> sendHeartbeat(emitter, clientGone),
|
() -> sendHeartbeat(emitter, clientGone),
|
||||||
|
status -> sendEvent(emitter, clientGone, "status",
|
||||||
|
Map.of("message", status != null ? status : "")),
|
||||||
proposal -> {
|
proposal -> {
|
||||||
sendEvent(emitter, clientGone, "done", proposal);
|
sendEvent(emitter, clientGone, "done", proposal);
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ public class GameSystemController {
|
|||||||
bytes, filename,
|
bytes, filename,
|
||||||
progress -> sendImportEvent(emitter, clientGone, "progress", progress),
|
progress -> sendImportEvent(emitter, clientGone, "progress", progress),
|
||||||
() -> sendImportHeartbeat(emitter, clientGone),
|
() -> sendImportHeartbeat(emitter, clientGone),
|
||||||
|
status -> sendImportEvent(emitter, clientGone, "status",
|
||||||
|
Map.of("message", status != null ? status : "")),
|
||||||
result -> {
|
result -> {
|
||||||
sendImportEvent(emitter, clientGone, "done", result);
|
sendImportEvent(emitter, clientGone, "done", result);
|
||||||
emitter.complete();
|
emitter.complete();
|
||||||
|
|||||||
@@ -124,7 +124,18 @@ public class NotebookController {
|
|||||||
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
|
List<NotebookChatStreamer.Msg> history = service.getMessages(id).stream()
|
||||||
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
|
.map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent()))
|
||||||
.toList();
|
.toList();
|
||||||
List<String> sourceIds = service.readySourceIds(id);
|
// Sélection de l'UI (cases cochées) : on ne garde que les sources qui
|
||||||
|
// appartiennent bien à CE notebook et sont prêtes — un id étranger est
|
||||||
|
// ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5).
|
||||||
|
// Variable finale : elle est capturée par la lambda du taskExecutor.
|
||||||
|
List<String> readyIds = service.readySourceIds(id);
|
||||||
|
final List<String> sourceIds;
|
||||||
|
if (req.sourceIds() != null) {
|
||||||
|
var wanted = new java.util.HashSet<>(req.sourceIds());
|
||||||
|
sourceIds = readyIds.stream().filter(wanted::contains).toList();
|
||||||
|
} else {
|
||||||
|
sourceIds = readyIds;
|
||||||
|
}
|
||||||
String context = service.buildContext(nb.getCampaignId());
|
String context = service.buildContext(nb.getCampaignId());
|
||||||
|
|
||||||
boolean deep = req.deep() != null && req.deep();
|
boolean deep = req.deep() != null && req.deep();
|
||||||
@@ -220,5 +231,10 @@ public class NotebookController {
|
|||||||
|
|
||||||
public record CreateRequest(String campaignId, String name) {}
|
public record CreateRequest(String campaignId, String name) {}
|
||||||
public record RenameRequest(String name) {}
|
public record RenameRequest(String name) {}
|
||||||
public record ChatRequest(String message, Boolean deep) {}
|
/**
|
||||||
|
* @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour
|
||||||
|
* (cases cochées dans l'UI). Null = toutes les sources prêtes.
|
||||||
|
* Toujours intersecté avec les sources du notebook (sécurité).
|
||||||
|
*/
|
||||||
|
public record ChatRequest(String message, Boolean deep, List<String> sourceIds) {}
|
||||||
}
|
}
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.3-beta",
|
"version": "0.12.5-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.3-beta",
|
"version": "0.12.5-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^21.2.16",
|
"@angular/animations": "^21.2.16",
|
||||||
"@angular/common": "^21.2.16",
|
"@angular/common": "^21.2.16",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.12.3-beta",
|
"version": "0.12.5-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (importStatus) {
|
||||||
|
<p class="import-status" role="status">{{ importStatus }}</p>
|
||||||
|
}
|
||||||
@if (importCounts) {
|
@if (importCounts) {
|
||||||
<p class="import-counts">
|
<p class="import-counts">
|
||||||
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
|
Trouvé jusqu'ici : {{ importCounts.arcs }} arc(s) · {{ importCounts.chapters }} chapitre(s) · {{ importCounts.scenes }} scène(s) · {{ importCounts.npcs }} PNJ
|
||||||
|
|||||||
@@ -87,6 +87,16 @@
|
|||||||
|
|
||||||
.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; }
|
.import-counts { margin: 0.55rem 0 0; color: #9ca3af; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) :
|
||||||
|
// ambre pour signaler « ça travaille, mais il se passe quelque chose ».
|
||||||
|
.import-status {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.import-error,
|
.import-error,
|
||||||
.apply-error {
|
.apply-error {
|
||||||
margin: 1rem 0 0;
|
margin: 1rem 0 0;
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
importing = false;
|
importing = false;
|
||||||
importPhase = '';
|
importPhase = '';
|
||||||
importProgress: { current: number; total: number } | null = null;
|
importProgress: { current: number; total: number } | null = null;
|
||||||
|
/**
|
||||||
|
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
||||||
|
* en cours, pas l'historique.
|
||||||
|
*/
|
||||||
|
importStatus: string | null = null;
|
||||||
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
|
importCounts: { arcs: number; chapters: number; scenes: number; npcs: number } | null = null;
|
||||||
importError: string | null = null;
|
importError: string | null = null;
|
||||||
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
|
/** Vrai une fois la proposition reçue (on affiche l'arbre éditable). */
|
||||||
@@ -132,12 +138,15 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
this.applyError = null;
|
this.applyError = null;
|
||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
|
this.importStatus = null;
|
||||||
this.importCounts = null;
|
this.importCounts = null;
|
||||||
this.tree = [];
|
this.tree = [];
|
||||||
|
|
||||||
this.service.importStructureStream(this.campaignId, file).subscribe({
|
this.service.importStructureStream(this.campaignId, file).subscribe({
|
||||||
next: (ev) => {
|
next: (ev) => {
|
||||||
if (ev.type === 'progress') {
|
if (ev.type === 'progress') {
|
||||||
|
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
||||||
|
this.importStatus = null;
|
||||||
if (ev.total === 0) {
|
if (ev.total === 0) {
|
||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
@@ -149,10 +158,13 @@ export class CampaignImportComponent implements OnInit {
|
|||||||
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
|
scenes: ev.sceneCount, npcs: ev.npcCount ?? 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else if (ev.type === 'status') {
|
||||||
|
this.importStatus = ev.message;
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
this.importing = false;
|
this.importing = false;
|
||||||
this.importPhase = '';
|
this.importPhase = '';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
|
this.importStatus = null;
|
||||||
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
|
if ((ev.arcs ?? []).length === 0 && (ev.npcs ?? []).length === 0) {
|
||||||
this.importError = "Aucune structure narrative détectée dans ce PDF.";
|
this.importError = "Aucune structure narrative détectée dans ce PDF.";
|
||||||
this.reviewing = false;
|
this.reviewing = false;
|
||||||
|
|||||||
@@ -20,13 +20,30 @@
|
|||||||
@if (uploadError) {
|
@if (uploadError) {
|
||||||
<p class="nbd-upload-error">{{ uploadError }}</p>
|
<p class="nbd-upload-error">{{ uploadError }}</p>
|
||||||
}
|
}
|
||||||
|
@if (readySourceCount > 1) {
|
||||||
|
<p class="nbd-sources-hint"
|
||||||
|
[class.partial]="selectedSourceIds.size < readySourceCount">
|
||||||
|
{{ selectedSourceIds.size }}/{{ readySourceCount }} source(s) utilisée(s) par le chat et l'analyse approfondie
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@for (s of sources; track s) {
|
@for (s of sources; track s) {
|
||||||
<div class="nbd-source">
|
<div class="nbd-source" [class.nbd-source--off]="s.status === 'READY' && !isSelected(s)">
|
||||||
<lucide-icon
|
@if (s.status === 'READY') {
|
||||||
[img]="s.status === 'READY' ? CheckCircle2 : (s.status === 'FAILED' ? AlertCircle : Loader)"
|
<input
|
||||||
[size]="14"
|
type="checkbox"
|
||||||
[class.ok]="s.status === 'READY'" [class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
|
class="nbd-source-check"
|
||||||
</lucide-icon>
|
[checked]="isSelected(s)"
|
||||||
|
(change)="toggleSource(s)"
|
||||||
|
[title]="isSelected(s)
|
||||||
|
? 'Source utilisée par le chat — décocher pour l\'exclure'
|
||||||
|
: 'Source ignorée par le chat — cocher pour l\'inclure'" />
|
||||||
|
} @else {
|
||||||
|
<lucide-icon
|
||||||
|
[img]="s.status === 'FAILED' ? AlertCircle : Loader"
|
||||||
|
[size]="14"
|
||||||
|
[class.fail]="s.status === 'FAILED'" [class.busy]="s.status === 'INDEXING'">
|
||||||
|
</lucide-icon>
|
||||||
|
}
|
||||||
<div class="nbd-source-info">
|
<div class="nbd-source-info">
|
||||||
<div class="nbd-source-name">{{ s.filename }}</div>
|
<div class="nbd-source-name">{{ s.filename }}</div>
|
||||||
<div class="nbd-source-meta">
|
<div class="nbd-source-meta">
|
||||||
|
|||||||
@@ -39,12 +39,36 @@
|
|||||||
}
|
}
|
||||||
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
|
.nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; }
|
||||||
|
|
||||||
|
// Compteur de sources actives — ambre quand une partie est décochée, pour
|
||||||
|
// rappeler que le chat ne voit pas tout.
|
||||||
|
.nbd-sources-hint {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted, #9aa0aa);
|
||||||
|
margin: 0.1rem 0 0.2rem;
|
||||||
|
|
||||||
|
&.partial { color: #fbbf24; }
|
||||||
|
}
|
||||||
|
|
||||||
.nbd-source {
|
.nbd-source {
|
||||||
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
|
display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem;
|
||||||
border-radius: 7px; background: rgba(255,255,255,0.03);
|
border-radius: 7px; background: rgba(255,255,255,0.03);
|
||||||
lucide-icon.ok { color: #6bd08a; }
|
lucide-icon.ok { color: #6bd08a; }
|
||||||
lucide-icon.fail { color: #e88; }
|
lucide-icon.fail { color: #e88; }
|
||||||
lucide-icon.busy { color: #e0c074; }
|
lucide-icon.busy { color: #e0c074; }
|
||||||
|
|
||||||
|
// Case « source utilisée par le chat » (sources prêtes uniquement).
|
||||||
|
.nbd-source-check {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
accent-color: #6bd08a;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source décochée : grisée = hors du périmètre du chat et de l'analyse.
|
||||||
|
&.nbd-source--off {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.nbd-source-info { flex: 1; min-width: 0; }
|
.nbd-source-info { flex: 1; min-width: 0; }
|
||||||
.nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; }
|
.nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; }
|
||||||
.nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
|
.nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); }
|
||||||
|
|||||||
@@ -137,13 +137,54 @@ export class NotebookDetailComponent implements OnInit {
|
|||||||
this.detail = d;
|
this.detail = d;
|
||||||
this.sources = d.sources ?? [];
|
this.sources = d.sources ?? [];
|
||||||
this.messages = d.messages ?? [];
|
this.messages = d.messages ?? [];
|
||||||
|
this.syncSelection();
|
||||||
},
|
},
|
||||||
error: () => this.back()
|
error: () => this.back()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
reloadSources(): void {
|
reloadSources(): void {
|
||||||
this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] });
|
this.service.get(this.notebookId).subscribe({ next: (d) => {
|
||||||
|
this.sources = d.sources ?? [];
|
||||||
|
this.syncSelection();
|
||||||
|
} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sélection des sources utilisées par le chat -------------------------
|
||||||
|
// Par défaut tout est coché ; décocher permet de limiter une question (et
|
||||||
|
// surtout une analyse approfondie, coûteuse en requêtes) à certains PDF.
|
||||||
|
|
||||||
|
/** IDs des sources cochées (sous-ensemble des sources READY). */
|
||||||
|
selectedSourceIds = new Set<string>();
|
||||||
|
/** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */
|
||||||
|
private knownSourceIds = new Set<string>();
|
||||||
|
|
||||||
|
/** Aligne la sélection sur la liste courante : nouvelles sources cochées par
|
||||||
|
* défaut, sources supprimées retirées, choix de l'utilisateur préservés. */
|
||||||
|
private syncSelection(): void {
|
||||||
|
const readyIds = new Set(this.sources.filter(s => s.status === 'READY').map(s => s.id));
|
||||||
|
for (const id of readyIds) {
|
||||||
|
if (!this.knownSourceIds.has(id)) {
|
||||||
|
this.selectedSourceIds.add(id);
|
||||||
|
this.knownSourceIds.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const id of [...this.selectedSourceIds]) {
|
||||||
|
if (!readyIds.has(id)) this.selectedSourceIds.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSelected(s: NotebookSource): boolean {
|
||||||
|
return this.selectedSourceIds.has(s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSource(s: NotebookSource): void {
|
||||||
|
if (this.selectedSourceIds.has(s.id)) this.selectedSourceIds.delete(s.id);
|
||||||
|
else this.selectedSourceIds.add(s.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
get readySourceCount(): number {
|
||||||
|
return this.sources.filter(s => s.status === 'READY').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sources ---
|
// --- Sources ---
|
||||||
@@ -181,7 +222,7 @@ export class NotebookDetailComponent implements OnInit {
|
|||||||
this.messages.push(assistant);
|
this.messages.push(assistant);
|
||||||
this.sending = true;
|
this.sending = true;
|
||||||
|
|
||||||
this.service.streamChat(this.notebookId, text, deep).subscribe({
|
this.service.streamChat(this.notebookId, text, deep, [...this.selectedSourceIds]).subscribe({
|
||||||
next: (ev) => {
|
next: (ev) => {
|
||||||
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
|
if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; }
|
||||||
else if (ev.type === 'sources') assistant.sources = ev.sources;
|
else if (ev.type === 'sources') assistant.sources = ev.sources;
|
||||||
|
|||||||
@@ -60,6 +60,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (importStatus) {
|
||||||
|
<p class="import-status" role="status">{{ importStatus }}</p>
|
||||||
|
}
|
||||||
@if (importFound.length) {
|
@if (importFound.length) {
|
||||||
<p class="import-found">
|
<p class="import-found">
|
||||||
Sections trouvées : {{ importFound.join(' · ') }}
|
Sections trouvées : {{ importFound.join(' · ') }}
|
||||||
|
|||||||
@@ -163,6 +163,16 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Message d'attente live (fournisseur saturé → retry, morceau re-découpé…) :
|
||||||
|
// ambre pour signaler « ça travaille, mais il se passe quelque chose ».
|
||||||
|
.import-status {
|
||||||
|
margin: 0.55rem 0 0;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-style: italic;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.import-note {
|
.import-note {
|
||||||
margin: -0.4rem 0 1rem;
|
margin: -0.4rem 0 1rem;
|
||||||
padding: 0.55rem 0.8rem;
|
padding: 0.55rem 0.8rem;
|
||||||
|
|||||||
@@ -69,6 +69,12 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
importProgress: { current: number; total: number } | null = null;
|
importProgress: { current: number; total: number } | null = null;
|
||||||
/** Titres de sections trouvés au fil de l'eau (affichage live). */
|
/** Titres de sections trouvés au fil de l'eau (affichage live). */
|
||||||
importFound: string[] = [];
|
importFound: string[] = [];
|
||||||
|
/**
|
||||||
|
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
|
||||||
|
* en cours, pas l'historique.
|
||||||
|
*/
|
||||||
|
importStatus: string | null = null;
|
||||||
|
|
||||||
name = '';
|
name = '';
|
||||||
description = '';
|
description = '';
|
||||||
@@ -139,10 +145,13 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
this.importProgress = null;
|
this.importProgress = null;
|
||||||
this.importFound = [];
|
this.importFound = [];
|
||||||
|
this.importStatus = null;
|
||||||
|
|
||||||
this.service.importRulesStream(file).subscribe({
|
this.service.importRulesStream(file).subscribe({
|
||||||
next: (ev) => {
|
next: (ev) => {
|
||||||
if (ev.type === 'progress') {
|
if (ev.type === 'progress') {
|
||||||
|
// Un morceau vient d'aboutir : le message d'attente est obsolète.
|
||||||
|
this.importStatus = null;
|
||||||
if (ev.total === 0) {
|
if (ev.total === 0) {
|
||||||
// Phase d'extraction (total encore inconnu).
|
// Phase d'extraction (total encore inconnu).
|
||||||
this.importPhase = 'Extraction du texte…';
|
this.importPhase = 'Extraction du texte…';
|
||||||
@@ -154,6 +163,8 @@ export class GameSystemEditComponent implements OnInit {
|
|||||||
if (!this.importFound.includes(t)) this.importFound.push(t);
|
if (!this.importFound.includes(t)) this.importFound.push(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (ev.type === 'status') {
|
||||||
|
this.importStatus = ev.message;
|
||||||
} else if (ev.type === 'done') {
|
} else if (ev.type === 'done') {
|
||||||
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
|
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,10 +63,13 @@ export interface CampaignImportApplyResult {
|
|||||||
/**
|
/**
|
||||||
* Évènements du flux SSE d'import streamé.
|
* Évènements du flux SSE d'import streamé.
|
||||||
* - progress : avancement (total=0 ⇒ extraction en cours).
|
* - progress : avancement (total=0 ⇒ extraction en cours).
|
||||||
|
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
|
||||||
* - done : arbre proposé (à réviser).
|
* - done : arbre proposé (à réviser).
|
||||||
* - error : message d'erreur côté serveur.
|
* - error : message d'erreur côté serveur.
|
||||||
*/
|
*/
|
||||||
export type CampaignImportStreamEvent =
|
export type CampaignImportStreamEvent =
|
||||||
|
| { type: 'status'; message: string }
|
||||||
| {
|
| {
|
||||||
type: 'progress';
|
type: 'progress';
|
||||||
current: number;
|
current: number;
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ export class CampaignImportService {
|
|||||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
||||||
terminated = true;
|
terminated = true;
|
||||||
subscriber.error(new Error(message));
|
subscriber.error(new Error(message));
|
||||||
|
} else if (name === 'status') {
|
||||||
|
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(currentData) as { message?: string };
|
||||||
|
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||||
|
} catch { /* bloc malformé ignoré */ }
|
||||||
} else if (name === 'progress' || name === 'done') {
|
} else if (name === 'progress' || name === 'done') {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(currentData);
|
const obj = JSON.parse(currentData);
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ export interface RulesImportResponse {
|
|||||||
/**
|
/**
|
||||||
* Évènements du flux SSE d'import streamé.
|
* Évènements du flux SSE d'import streamé.
|
||||||
* - progress : avancement (total=0 ⇒ phase d'extraction en cours).
|
* - progress : avancement (total=0 ⇒ phase d'extraction en cours).
|
||||||
|
* - status : message d'attente lisible (fournisseur saturé → retry, morceau
|
||||||
|
* re-découpé, morceau ignoré…) — feedback live pour l'utilisateur.
|
||||||
* - done : résultat final (sections proposées).
|
* - done : résultat final (sections proposées).
|
||||||
* - error : message d'erreur côté serveur.
|
* - error : message d'erreur côté serveur.
|
||||||
*/
|
*/
|
||||||
export type RulesImportStreamEvent =
|
export type RulesImportStreamEvent =
|
||||||
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
|
| { type: 'progress'; current: number; total: number; pageCount: number; ocrPageCount: number; newSectionTitles: string[] }
|
||||||
|
| { type: 'status'; message: string }
|
||||||
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
|
| { type: 'done'; sections: Record<string, string>; pageCount: number; ocrPageCount: number }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ export class GameSystemService {
|
|||||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
|
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* garde le défaut */ }
|
||||||
terminated = true;
|
terminated = true;
|
||||||
subscriber.error(new Error(message));
|
subscriber.error(new Error(message));
|
||||||
|
} else if (name === 'status') {
|
||||||
|
// Message d'attente lisible (fournisseur saturé, morceau re-découpé…).
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(currentData) as { message?: string };
|
||||||
|
if (obj.message) subscriber.next({ type: 'status', message: obj.message });
|
||||||
|
} catch { /* bloc malformé ignoré */ }
|
||||||
} else if (name === 'progress' || name === 'done') {
|
} else if (name === 'progress' || name === 'done') {
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(currentData);
|
const obj = JSON.parse(currentData);
|
||||||
|
|||||||
@@ -47,8 +47,11 @@ export class NotebookService {
|
|||||||
/**
|
/**
|
||||||
* Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
|
* Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE).
|
||||||
* Émissions forcées dans la zone Angular pour la détection de changement.
|
* Émissions forcées dans la zone Angular pour la détection de changement.
|
||||||
|
*
|
||||||
|
* `sourceIds` : sous-ensemble de sources à utiliser pour ce tour (cases cochées) ;
|
||||||
|
* undefined = toutes les sources prêtes du notebook.
|
||||||
*/
|
*/
|
||||||
streamChat(notebookId: string, message: string, deep = false): Observable<NotebookChatEvent> {
|
streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[]): Observable<NotebookChatEvent> {
|
||||||
return new Observable<NotebookChatEvent>((subscriber) => {
|
return new Observable<NotebookChatEvent>((subscriber) => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
|
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
|
||||||
@@ -59,7 +62,7 @@ export class NotebookService {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
|
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({ message, deep }),
|
body: JSON.stringify({ message, deep, sourceIds: sourceIds ?? null }),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok || !response.body) {
|
||||||
|
|||||||
Reference in New Issue
Block a user