267 lines
9.8 KiB
Python
267 lines
9.8 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import argparse
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
from core.chapters import ChapterConflictError, ChapterError, ChapterId, resolve_chapter_file
|
|
from core.generation.pipeline import GenerationPipeline
|
|
from core.generation.provider import ProviderConfigurationError, ProviderError
|
|
from core.project.loader import ProjectState
|
|
|
|
|
|
def cmd_status(root: Path):
|
|
project = ProjectState(root)
|
|
state = project.summary()
|
|
|
|
print("\nAI Novel Engine — Project Status\n")
|
|
|
|
current = state["current_chapter"]
|
|
if current is None:
|
|
print("Chapitre courant : aucun")
|
|
else:
|
|
print(f"Chapitre courant : {current}")
|
|
|
|
print("\nDossiers:")
|
|
for label, key in (
|
|
("Structure", "structure"),
|
|
("Brouillons", "drafts"),
|
|
("Manuscrit", "manuscript"),
|
|
("Memoire", "memory"),
|
|
):
|
|
print(f"- {label:<10}: {state['directories'][key]}")
|
|
|
|
latest_drafts = state["latest_drafts"]
|
|
if latest_drafts:
|
|
print("\nDerniers brouillons:")
|
|
for chapter_slug, draft_name in sorted(latest_drafts.items()):
|
|
print(f"- {chapter_slug}: {draft_name}")
|
|
else:
|
|
print("\nDerniers brouillons: aucun")
|
|
|
|
latest_repairs = state["latest_repairs"]
|
|
if latest_repairs:
|
|
print("\nDernières réparations:")
|
|
for chapter_slug, repair_name in sorted(latest_repairs.items()):
|
|
print(f"- {chapter_slug}: {repair_name}")
|
|
else:
|
|
print("\nDernières réparations: aucune")
|
|
|
|
failures = state["failed_chapters"]
|
|
if failures:
|
|
print("\nChapitres en échec:")
|
|
for item in failures:
|
|
retry_suffix = ""
|
|
if item["retry_stages"]:
|
|
retry_suffix = f" | réessais JSON: {', '.join(item['retry_stages'])}"
|
|
status_message = f" | message: {item['last_status_message']}" if item["last_status_message"] else ""
|
|
print(
|
|
f"- {item['chapter']}: status={item['status']} | failed_stage={item['failed_stage']} | meta={item['meta_path']}{retry_suffix}{status_message}"
|
|
)
|
|
else:
|
|
print("\nChapitres en échec: aucun")
|
|
|
|
quality_blocked = state["quality_blocked_chapters"]
|
|
if quality_blocked:
|
|
print("\nBloqués par garde-fou:")
|
|
for item in quality_blocked:
|
|
retry_suffix = ""
|
|
if item["retry_stages"]:
|
|
retry_suffix = f" | réessais JSON: {', '.join(item['retry_stages'])}"
|
|
blockers_suffix = ""
|
|
if item["quality_blockers"]:
|
|
blockers_suffix = f" | blockers: {', '.join(item['quality_blockers'])}"
|
|
repair_suffix = ""
|
|
if item["repair_attempts"]:
|
|
repair_models = ", ".join(item["repair_models"]) if item["repair_models"] else "provider_courant"
|
|
repair_suffix = f" | réparations: {item['repair_attempts']} ({repair_models})"
|
|
status_message = f" | message: {item['last_status_message']}" if item["last_status_message"] else ""
|
|
print(
|
|
f"- {item['chapter']}: status={item['status']} | failed_stage={item['failed_stage']} | brouillon={item['draft_path']} | gate={item['gate_path']} | meta={item['meta_path']}{blockers_suffix}{repair_suffix}{retry_suffix}{status_message}"
|
|
)
|
|
else:
|
|
print("\nBloqués par garde-fou: aucun")
|
|
|
|
awaiting_acceptance = state["awaiting_acceptance"]
|
|
if awaiting_acceptance:
|
|
print("\nEn attente de validation:")
|
|
for item in awaiting_acceptance:
|
|
retry_suffix = ""
|
|
if item["retry_stages"]:
|
|
retry_suffix = f" | réessais JSON: {', '.join(item['retry_stages'])}"
|
|
repair_suffix = ""
|
|
if item["repair_attempts"]:
|
|
repair_models = ", ".join(item["repair_models"]) if item["repair_models"] else "provider_courant"
|
|
repair_suffix = f" | réparations: {item['repair_attempts']} ({repair_models})"
|
|
status_message = f" | message: {item['last_status_message']}" if item["last_status_message"] else ""
|
|
print(
|
|
f"- {item['chapter']}: status={item['status']} | brouillon={item['draft_path']} | critique={item['critique_path']} | gate={item['gate_path']}{repair_suffix}{retry_suffix}{status_message}"
|
|
)
|
|
else:
|
|
print("\nEn attente de validation: aucun")
|
|
|
|
corrupted_meta = state.get("corrupted_meta", [])
|
|
if corrupted_meta:
|
|
print("\nMétadonnées corrompues:")
|
|
for item in corrupted_meta:
|
|
print(f"- {item['chapter']}: meta={item['meta_path']} | erreur: {item['error']}")
|
|
else:
|
|
print("\nMétadonnées corrompues: aucune")
|
|
|
|
print("")
|
|
return 0
|
|
|
|
|
|
def cmd_intention_create(root: Path, chapter_value: str | None = None, input_func=input):
|
|
intentions_dir = root / "notes" / "intentions"
|
|
intentions_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
raw_chapter = chapter_value or input_func("Numéro du chapitre (ex: 08) : ").strip()
|
|
if not re.match(r'^[a-zA-Z0-9_]+$', raw_chapter):
|
|
print("Numéro de chapitre invalide (alphanumériques et _ uniquement).")
|
|
return 1
|
|
chapter = ChapterId.parse(raw_chapter)
|
|
|
|
path = resolve_chapter_file(intentions_dir, chapter)
|
|
if path.exists():
|
|
print(f"Une intention existe déjà : {path}")
|
|
return 1
|
|
|
|
print("\nDécris l'intention (finir par Ctrl+D / Ctrl+Z):\n")
|
|
lines = []
|
|
try:
|
|
while True:
|
|
lines.append(input_func(""))
|
|
except EOFError:
|
|
pass
|
|
|
|
content = "\n".join(line for line in lines if line is not None).strip()
|
|
if not content:
|
|
print("Intention vide. Annulé.")
|
|
return 1
|
|
|
|
canonical_path = intentions_dir / chapter.filename
|
|
canonical_path.write_text(
|
|
f"# Intention — Chapitre {chapter.label}\n\n{content}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
print(f"Intention créée : {canonical_path}\n")
|
|
return 0
|
|
|
|
|
|
def _approval_callback_from_flags(force_accept: bool | None):
|
|
if force_accept is None:
|
|
return None
|
|
return lambda _report, _path: force_accept
|
|
|
|
|
|
def cmd_generate_chapter(
|
|
root: Path,
|
|
chapter_value: str,
|
|
provider=None,
|
|
input_func=input,
|
|
*,
|
|
force_accept: bool | None = None,
|
|
):
|
|
prompt_profile = os.environ.get("ANE_PROMPT_PROFILE") or None
|
|
pipeline = GenerationPipeline(root, provider=provider, input_func=input_func, prompt_profile=prompt_profile)
|
|
outcome = pipeline.generate_chapter(
|
|
chapter_value,
|
|
approval_callback=_approval_callback_from_flags(force_accept),
|
|
)
|
|
|
|
print("")
|
|
print(f"Chapitre traité : {outcome.chapter_id.slug}")
|
|
print(f"Statut : {outcome.status}")
|
|
print(f"Brouillon final : {outcome.draft_path}")
|
|
print(f"Critique : {outcome.critique_path}")
|
|
print(f"Garde-fou : {outcome.gate_path}")
|
|
print(f"Métadonnées : {outcome.meta_path}")
|
|
if outcome.accepted and outcome.manuscript_path is not None:
|
|
print(f"Manuscrit : {outcome.manuscript_path}")
|
|
else:
|
|
print("Manuscrit : non promu")
|
|
if outcome.quality_blockers:
|
|
print(f"Blockers : {', '.join(outcome.quality_blockers)}")
|
|
print("")
|
|
return 0
|
|
|
|
|
|
def _add_approval_flags(parser: argparse.ArgumentParser) -> None:
|
|
group = parser.add_mutually_exclusive_group()
|
|
group.add_argument("--approve", action="store_true", help="Promouvoir sans confirmation interactive.")
|
|
group.add_argument("--reject", action="store_true", help="Refuser sans confirmation interactive.")
|
|
|
|
|
|
def _force_accept_from_namespace(namespace: argparse.Namespace) -> bool | None:
|
|
if getattr(namespace, "approve", False):
|
|
return True
|
|
if getattr(namespace, "reject", False):
|
|
return False
|
|
return None
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(prog="python3 -m cli.main")
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
subparsers.add_parser("status")
|
|
|
|
intention_parser = subparsers.add_parser("intention")
|
|
intention_subparsers = intention_parser.add_subparsers(dest="intention_command")
|
|
intention_create = intention_subparsers.add_parser("create")
|
|
intention_create.add_argument("--chapter")
|
|
|
|
generate_parser = subparsers.add_parser("generate")
|
|
generate_subparsers = generate_parser.add_subparsers(dest="generate_target")
|
|
generate_chapter = generate_subparsers.add_parser("chapter")
|
|
generate_chapter.add_argument("--chapter", required=True)
|
|
_add_approval_flags(generate_chapter)
|
|
|
|
write_parser = subparsers.add_parser("write")
|
|
write_parser.add_argument("--chapter", required=True)
|
|
_add_approval_flags(write_parser)
|
|
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None, root: Path | None = None):
|
|
args = argv if argv is not None else sys.argv[1:]
|
|
project_root = root or Path.cwd()
|
|
parser = build_parser()
|
|
namespace = parser.parse_args(args)
|
|
|
|
if not args or namespace.command == "status":
|
|
return cmd_status(project_root)
|
|
|
|
try:
|
|
if namespace.command == "intention" and namespace.intention_command == "create":
|
|
return cmd_intention_create(project_root, chapter_value=namespace.chapter)
|
|
|
|
if namespace.command == "generate" and namespace.generate_target == "chapter":
|
|
return cmd_generate_chapter(
|
|
project_root,
|
|
namespace.chapter,
|
|
force_accept=_force_accept_from_namespace(namespace),
|
|
)
|
|
|
|
if namespace.command == "write":
|
|
return cmd_generate_chapter(
|
|
project_root,
|
|
namespace.chapter,
|
|
force_accept=_force_accept_from_namespace(namespace),
|
|
)
|
|
except (ChapterError, ChapterConflictError, RuntimeError, ProviderConfigurationError, ProviderError) as exc:
|
|
print(f"\nErreur: {exc}\n")
|
|
return 1
|
|
|
|
parser.print_help()
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|